Remove most occurences of useStrictMemo

This commit is contained in:
Stan Wohlwend 2024-03-06 05:36:37 +01:00
parent 3737f68e07
commit db5a2c859e
27 changed files with 438 additions and 373 deletions

View File

@ -1,23 +1,17 @@
"use client";
import { AccordionGroup, Card, CardOverflow } from "@mui/joy";
import { use, useState } from "react";
import { Paragraph } from "@/components/paragraph";
import { useStrictMemo } from "@stackframe/stack-shared/src/hooks/use-strict-memo";
import { SmartSwitch } from "@/components/smart-switch";
import { SimpleCard } from "@/components/simple-card";
import { useAdminApp } from "../../useAdminInterface";
import { ProviderAccordion, ProviderType, availableProviders } from "./provider-accordion";
import { useAdminApp } from "../../use-admin-app";
import { ProviderAccordion, availableProviders } from "./provider-accordion";
import { OauthProviderConfigJson } from "@stackframe/stack-shared";
export default function ProvidersClient() {
const stackAdminApp = useAdminApp();
const [invalidationCounter, setInvalidationCounter] = useState(0);
const projectPromise = useStrictMemo(async () => {
return await stackAdminApp.getProject({ showDisabledOauth: true });
}, [stackAdminApp, invalidationCounter]);
const project = use(projectPromise);
const project = stackAdminApp.useProjectAdmin();
const oauthProviders = project.evaluatedConfig.oauthProviders;
@ -33,12 +27,11 @@ export default function ProvidersClient() {
<SmartSwitch
checked={project.evaluatedConfig.credentialEnabled}
onChange={async (event) => {
await stackAdminApp.updateProject({
await project.update({
config: {
credentialEnabled: event.target.checked,
},
});
setInvalidationCounter((counter) => counter + 1);
}}
>
Enable password authentication
@ -65,10 +58,9 @@ export default function ProvidersClient() {
newOauthProviders.push(provider);
}
await stackAdminApp.updateProject({
await project.update({
config: { oauthProviders: newOauthProviders },
});
setInvalidationCounter((counter) => counter + 1);
}}
/>;
})}

View File

@ -26,7 +26,7 @@ import { useEffect, useMemo, useState } from "react";
import { Paragraph } from "@/components/paragraph";
import { AsyncButton } from "@/components/async-button";
import { SharedProvider, StandardProvider, sharedProviders, standardProviders, toSharedProvider, toStandardProvider } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { useAdminApp } from "../../useAdminInterface";
import { useAdminApp } from "../../use-admin-app";
import { SmartSwitch } from "@/components/smart-switch";
import { Dialog } from "@/components/dialog";
import { DialogContent, Icon } from "@mui/material";

View File

@ -6,19 +6,14 @@ import { Paragraph } from "@/components/paragraph";
import { Icon } from "@/components/icon";
import { Dialog } from "@/components/dialog";
import { AsyncButton } from "@/components/async-button";
import { useStrictMemo } from "@stackframe/stack-shared/src/hooks/use-strict-memo";
import { SimpleCard } from "@/components/simple-card";
import { useAdminApp } from "../../useAdminInterface";
import { useAdminApp } from "../../use-admin-app";
import { SmartSwitch } from "@/components/smart-switch";
export default function UrlsAndCallbacksClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();
const [invalidationCounter, setInvalidationCounter] = useState(0);
const projectPromise = useStrictMemo(async () => {
return await stackAdminApp.getProject();
}, [stackAdminApp, invalidationCounter]);
const project = use(projectPromise);
const domains = new Set(project.evaluatedConfig.domains);
const [deleteDialogPrefix, setDeleteDialogDomain] = useState<string | null>(null);
@ -38,12 +33,11 @@ export default function UrlsAndCallbacksClient() {
<SmartSwitch
checked={project.evaluatedConfig.allowLocalhost}
onChange={async (event) => {
await stackAdminApp.updateProject({
await project.update({
config: {
allowLocalhost: event.target.checked,
},
});
setInvalidationCounter(invalidationCounter + 1);
}}
>
<Typography>Allow all localhost callbacks for development</Typography>
@ -97,12 +91,11 @@ export default function UrlsAndCallbacksClient() {
okButton={{
label: "Delete",
onClick: async () => {
await stackAdminApp.updateProject({
await project.update({
config: {
domains: [...domains].filter(({ domain }) => domain !== deleteDialogPrefix),
}
});
setInvalidationCounter(invalidationCounter + 1);
}
}}
cancelButton
@ -130,7 +123,7 @@ export default function UrlsAndCallbacksClient() {
setNewDomainError(true);
return "prevent-close";
}
await stackAdminApp.updateProject({
await project.update({
config: {
domains: [...domains, {
domain: newDomain,
@ -138,7 +131,6 @@ export default function UrlsAndCallbacksClient() {
}],
},
});
setInvalidationCounter(invalidationCounter + 1);
}
}}
cancelButton

View File

@ -1,21 +1,14 @@
"use client";
import { use, useState } from "react";
import { useState } from "react";
import { Paragraph } from "@/components/paragraph";
import { useStrictMemo } from "@stackframe/stack-shared/src/hooks/use-strict-memo";
import { UsersTable } from "./users-table";
import { useAdminApp } from "../../useAdminInterface";
import { useAdminApp } from "../../use-admin-app";
export default function UsersDashboardClient() {
const stackAdminApp = useAdminApp();
const [invalidationCounter, setInvalidationCounter] = useState(0);
const allUsersPromise = useStrictMemo(() => {
return stackAdminApp.listUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invalidationCounter]);
const allUsers = use(allUsersPromise);
const allUsers = stackAdminApp.useServerUsers();
return (
<>
@ -24,7 +17,7 @@ export default function UsersDashboardClient() {
</Paragraph>
<Paragraph body>
<UsersTable rows={allUsers} onInvalidate={() => setInvalidationCounter(() => invalidationCounter + 1)} />
<UsersTable rows={allUsers} />
</Paragraph>
</>
);

View File

@ -7,18 +7,15 @@ import { getInputDatetimeLocalString } from '@stackframe/stack-shared/dist/utils
import { Icon } from '@/components/icon';
import { AsyncButton } from '@/components/async-button';
import { Dialog } from '@/components/dialog';
import { useAdminApp } from '../../useAdminInterface';
import { useAdminApp } from '../../use-admin-app';
import { runAsynchronously } from '@stackframe/stack-shared/src/utils/promises';
import { ServerUserJson } from '@stackframe/stack-shared';
import { ServerUser } from '@stackframe/stack/dist/lib/stack-app';
export function UsersTable(props: {
rows: ServerUserJson[],
onInvalidate(): void,
rows: ServerUser[],
}) {
const stackAdminApp = useAdminApp();
const columns: (GridColDef & {
stackOnProcessUpdate?: (updatedRow: ServerUserJson, oldRow: ServerUserJson) => Promise<void>,
stackOnProcessUpdate?: (updatedRow: ServerUser, oldRow: ServerUser) => Promise<void>,
})[] = [
{
field: 'profilePicture',
@ -51,7 +48,7 @@ export function UsersTable(props: {
flex: 1,
editable: true,
stackOnProcessUpdate: async (updatedRow, originalRow) => {
await stackAdminApp.setServerUserCustomizableData(originalRow.id, { displayName: updatedRow.displayName });
await originalRow.update({ displayName: updatedRow.displayName });
},
},
{
@ -60,7 +57,7 @@ export function UsersTable(props: {
width: 200,
editable: true,
stackOnProcessUpdate: async (updatedRow, originalRow) => {
await stackAdminApp.setServerUserCustomizableData(originalRow.id, { primaryEmail: updatedRow.primaryEmail, primaryEmailVerified: false });
await originalRow.update({ primaryEmail: updatedRow.primaryEmail, primaryEmailVerified: false });
},
renderCell: (params) => (
<>
@ -95,7 +92,7 @@ export function UsersTable(props: {
type: 'actions',
width: 48,
getActions: (params) => [
<Actions key="more_actions" params={params} onInvalidate={() => props.onInvalidate()} />
<Actions key="more_actions" params={params} />
],
},
];
@ -118,7 +115,6 @@ export function UsersTable(props: {
await column.stackOnProcessUpdate(updatedRow, originalRow);
}
}
props.onInvalidate();
return originalRow;
}}
pageSizeOptions={[5, 15, 25]}
@ -126,7 +122,7 @@ export function UsersTable(props: {
);
}
function Actions(props: { params: any, onInvalidate: () => void}) {
function Actions(props: { params: any }) {
const stackAdminApp = useAdminApp();
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
@ -161,7 +157,6 @@ function Actions(props: { params: any, onInvalidate: () => void}) {
user={props.params.row}
open={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
onInvalidate={() => props.onInvalidate()}
/>
<Dialog
@ -172,8 +167,7 @@ function Actions(props: { params: any, onInvalidate: () => void}) {
okButton={{
label: "Delete user",
onClick: async () => {
await stackAdminApp.deleteServerUser(props.params.row.id);
props.onInvalidate();
await props.params.row.delete();
}
}}
cancelButton={true}
@ -185,7 +179,7 @@ function Actions(props: { params: any, onInvalidate: () => void}) {
}
function EditUserModal(props: { user: ServerUserJson, open: boolean, onClose: () => void, onInvalidate: () => void }) {
function EditUserModal(props: { user: ServerUser, open: boolean, onClose: () => void }) {
const stackAdminApp = useAdminApp();
const formRef = React.useRef<HTMLFormElement>(null);
@ -213,8 +207,7 @@ function EditUserModal(props: { user: ServerUserJson, open: boolean, onClose: ()
primaryEmailVerified: formData.get('primaryEmailVerified') === "on",
signedUpAtMillis: new Date(formData.get('signedUpAt') as string).getTime(),
};
await stackAdminApp.setServerUserCustomizableData(props.user.id, formJson);
props.onInvalidate();
await props.user.update(formJson);
props.onClose();
} finally {
setIsSaving(false);
@ -246,7 +239,7 @@ function EditUserModal(props: { user: ServerUserJson, open: boolean, onClose: ()
</Stack>
<FormControl disabled={isSaving}>
<FormLabel htmlFor="signedUpAt">Signed up</FormLabel>
<Input name="signedUpAt" type="datetime-local" defaultValue={getInputDatetimeLocalString(new Date(props.user.signedUpAtMillis))} required />
<Input name="signedUpAt" type="datetime-local" defaultValue={getInputDatetimeLocalString(props.user.signedUpAt)} required />
</FormControl>
</Stack>
</form>

View File

@ -4,9 +4,8 @@ import { Sheet, SheetProps, Select, Option, SelectOption, Stack, Typography } fr
import * as React from 'react';
import Box from '@mui/joy/Box';
import IconButton from '@mui/joy/IconButton';
import { useAdminApp } from './useAdminInterface';
import { useAdminApp } from './use-admin-app';
import { redirect, usePathname } from 'next/navigation';
import { useStrictMemo } from '@stackframe/stack-shared/src/hooks/use-strict-memo';
import { useStackApp } from '@stackframe/stack';
import { Icon } from '@/components/icon';
import Breadcrumbs from '@mui/joy/Breadcrumbs';
@ -40,10 +39,7 @@ function ProjectSwitchItem({ label }: { label: string }) {
function ProjectSwitch() {
const stackApp = useStackApp({ projectIdMustMatch: "internal" });
const stackAdminApp = useAdminApp();
const projectsPromise = useStrictMemo(() => {
return stackApp.listOwnedProjects();
}, []);
const projects = React.use(projectsPromise);
const projects = stackApp.useOwnedProjects();
const project = projects.find((project) => project.id === stackAdminApp.projectId);
const renderValue = (option: SelectOption<string> | null) => {

View File

@ -3,7 +3,7 @@
import { Box, Drawer, Stack, useTheme } from "@mui/joy";
import { useState } from "react";
import { Sidebar } from "./sidebar";
import { AdminAppProvider } from "./useAdminInterface";
import { AdminAppProvider } from "./use-admin-app";
import { Header } from "./header";
import { Icon } from '@/components/icon';
import { OnboardingDialog } from "./onboarding-dialog";

View File

@ -1,7 +1,7 @@
import { Dialog } from "@/components/dialog";
import { use, useId, useRef, useState } from "react";
import { useStrictMemo } from "@stackframe/stack-shared/src/hooks/use-strict-memo";
import { useAdminApp } from "./useAdminInterface";
import { useAdminApp } from "./use-admin-app";
import { Stack } from "@mui/joy";
import { Paragraph } from "@/components/paragraph";
import EnvKeys from "@/components/env-keys";

View File

@ -2,18 +2,17 @@
import * as React from 'react';
import { DataGrid, GridColDef, GridToolbar } from '@mui/x-data-grid';
import { ApiKeySetSummary } from '@stackframe/stack-shared';
import { Box, Checkbox, Stack, Tooltip, Typography } from '@mui/joy';
import { Dialog } from '@/components/dialog';
import { useAdminApp } from '../../useAdminInterface';
import { useAdminApp } from '../../use-admin-app';
import { ApiKeySet } from '@stackframe/stack/dist/lib/stack-app';
export function ApiKeysTable(props: {
rows: ApiKeySetSummary[],
onInvalidate(): void,
rows: ApiKeySet[],
}) {
const stackAdminApp = useAdminApp();
const [revokeDialogApiKeySet, setRevokeDialogApiKeySet] = React.useState<ApiKeySetSummary | null>(null);
const [revokeDialogApiKeySet, setRevokeDialogApiKeySet] = React.useState<ApiKeySet | null>(null);
const columns: GridColDef[] = [
{
@ -128,9 +127,8 @@ export function ApiKeysTable(props: {
label: "Revoke API key",
onClick: async () => {
if (revokeDialogApiKeySet) {
await stackAdminApp.revokeApiKeySetById(revokeDialogApiKeySet.id);
await revokeDialogApiKeySet.revoke();
setRevokeDialogApiKeySet(null);
props.onInvalidate();
}
},
}}

View File

@ -2,36 +2,22 @@
import { use, useId, useState } from "react";
import { Box, Button, Checkbox, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormLabel, Input, Modal, ModalDialog, Select, Option, Stack, FormHelperText, Typography } from "@mui/joy";
import { ApiKeySetFirstView } from "@stackframe/stack-shared";
import { AsyncButton } from "@/components/async-button";
import { Icon } from "@/components/icon";
import { Paragraph } from "@/components/paragraph";
import { CopyButton } from "@/components/copy-button";
import { useStrictMemo } from "@stackframe/stack-shared/src/hooks/use-strict-memo";
import { ApiKeysTable } from "./api-keys-table";
import { useAdminApp } from "../../useAdminInterface";
import { useAdminApp } from "../../use-admin-app";
import { runAsynchronously } from "@stackframe/stack-shared/src/utils/promises";
import EnvKeys from "@/components/env-keys";
import Link from "next/link";
import { SmartLink } from "@/components/smart-link";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { ApiKeySetFirstView } from "@stackframe/stack/dist/lib/stack-app";
export default function ApiKeysDashboardClient() {
const stackAdminApp = useAdminApp();
const [invalidationCounter, setInvalidationCounter] = useState(0);
const apiKeySetsPromise = useStrictMemo(() => {
return stackAdminApp.listApiKeySets();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invalidationCounter]);
const apiKeySets = use(apiKeySetsPromise);
const apiKeySets = stackAdminApp.useApiKeySets();
const [isNewApiKeyDialogOpen, setIsNewApiKeyDialogOpen] = useState(false);
const invalidate = () => {
setInvalidationCounter(c => c + 1);
};
return (
<>
@ -50,13 +36,12 @@ export default function ApiKeysDashboardClient() {
Please note that API keys cannot be viewed anymore after they have been created. If you lose them, you will have to create new ones.
</Paragraph>
<ApiKeysTable rows={apiKeySets} onInvalidate={() => invalidate()} />
<ApiKeysTable rows={apiKeySets} />
<CreateNewDialog
key={`${isNewApiKeyDialogOpen}`}
open={isNewApiKeyDialogOpen}
onClose={() => setIsNewApiKeyDialogOpen(false)}
onInvalidate={() => invalidate()}
/>
</>
);
@ -71,8 +56,9 @@ const expiresInOptions = {
[1000 * 60 * 60 * 24 * 365 * 200]: "Never",
} as const;
function CreateNewDialog(props: { open: boolean, onClose(): void, onInvalidate(): void }) {
function CreateNewDialog(props: { open: boolean, onClose(): void }) {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();
const formId = useId();
const [isCreating, setIsCreating] = useState(false);
@ -80,11 +66,6 @@ function CreateNewDialog(props: { open: boolean, onClose(): void, onInvalidate()
const [returnedApiKey, setReturnedApiKey] = useState<ApiKeySetFirstView | null>(null);
const [confirmedOnlyOnce, setConfirmedOnlyOnce] = useState(false);
const projectPromise = useStrictMemo(() => {
return stackAdminApp.getProject();
}, []);
const project = use(projectPromise);
const close = () => {
if (returnedApiKey && !confirmedOnlyOnce) return;
props.onClose();
@ -148,7 +129,6 @@ function CreateNewDialog(props: { open: boolean, onClose(): void, onInvalidate()
description: formData.get("description") as string,
});
setReturnedApiKey(returned);
props.onInvalidate();
} finally {
setIsCreating(false);
}

View File

@ -1,29 +1,21 @@
"use client";
import { Typography } from "@mui/joy";
import React, { use, useState } from "react";
import React, { useState } from "react";
import { InlineCode } from "@/components/inline-code";
import { Paragraph } from "@/components/paragraph";
import { useStrictMemo } from "@stackframe/stack-shared/src/hooks/use-strict-memo";
import { IconAlert } from "@/components/icon-alert";
import { Enumeration, EnumerationItem } from "@/components/enumeration";
import { SmartLink } from "@/components/smart-link";
import { SmartSwitch } from "@/components/smart-switch";
import { SimpleCard } from "@/components/simple-card";
import { useAdminApp } from "../../useAdminInterface";
import { getProductionModeErrors } from "@stackframe/stack-shared";
import { useAdminApp } from "../../use-admin-app";
export default function EnvironmentClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();
const [invalidationCounter, setInvalidationCounter] = useState(0);
const projectPromise = useStrictMemo(async () => {
return await stackAdminApp.getProject();
// eslint-disable-next-line
}, [stackAdminApp, invalidationCounter]);
const project = use(projectPromise);
const productionModeErrors = getProductionModeErrors(project);
const productionModeErrors = project.getProductionModeErrors();
const [productionModeUpdateLoading, setProductionModeUpdateLoading] = useState(false);
@ -46,11 +38,10 @@ export default function EnvironmentClient() {
onChange={async (event) => {
setProductionModeUpdateLoading(true);
try {
await stackAdminApp.updateProject({
await project.update({
isProductionMode: event.target.checked,
});
} finally {
setInvalidationCounter((prev) => prev + 1);
setProductionModeUpdateLoading(false);
}
}}

View File

@ -10,7 +10,7 @@ import ListItem from '@mui/joy/ListItem';
import ListItemButton from '@mui/joy/ListItemButton';
import ListItemContent from '@mui/joy/ListItemContent';
import Typography from '@mui/joy/Typography';
import { useAdminApp } from './useAdminInterface';
import { useAdminApp } from './use-admin-app';
import { usePathname } from 'next/navigation';
import { useUser } from '@stackframe/stack';
import { Dropdown, MenuButton, MenuItem, Menu, useColorScheme, Stack, Sheet, CircularProgress } from '@mui/joy';

View File

@ -1,19 +1,19 @@
"use client";
import { StackAdminInterface } from "@stackframe/stack-shared";
import React from "react";
import { useUser } from "@stackframe/stack";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { cacheFunction } from "@stackframe/stack-shared/dist/utils/caches";
import { CurrentUser } from "@stackframe/stack/dist/lib/stack-app";
import { CurrentUser, StackAdminApp } from "@stackframe/stack/dist/lib/stack-app";
const StackAdminInterfaceContext = React.createContext<StackAdminInterface | null>(null);
const StackAdminInterfaceContext = React.createContext<StackAdminApp<true> | null>(null);
const createAdminInterface = cacheFunction((baseUrl: string, projectId: string, user: CurrentUser) => {
return new StackAdminInterface({
return new StackAdminApp({
baseUrl,
projectId,
internalAdminAccessToken: user.accessToken ?? throwErr("User must have an access token"),
tokenStore: "nextjs-cookie",
projectOwnerTokens: user.tokenStore,
});
});

View File

@ -8,19 +8,14 @@ import { Dialog } from "@/components/dialog";
import { Paragraph } from "@/components/paragraph";
import { SmartLink } from "@/components/smart-link";
import { useFromNow } from "@/hooks/use-from-now";
import { useStrictMemo } from "@stackframe/stack-shared/src/hooks/use-strict-memo";
import { runAsynchronously } from "@stackframe/stack-shared/src/utils/promises";
import { Project } from "@stackframe/stack/dist/lib/stack-app";
export default function ProjectsPageClient() {
const [invalidationCounter, setInvalidationCounter] = useState(0);
const stackApp = useStackApp({ projectIdMustMatch: "internal" });
const projectsPromise = useStrictMemo(() => {
return stackApp.listOwnedProjects();
}, [invalidationCounter]);
const projects = use(projectsPromise);
const projects = stackApp.useOwnedProjects();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
@ -47,7 +42,6 @@ export default function ProjectsPageClient() {
<CreateProjectDialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onInvalidate={() => setInvalidationCounter((x) => x + 1)}
/>
</>
);
@ -88,7 +82,7 @@ function ProjectCard(props: {
);
}
function CreateProjectDialog(props: { open: boolean, onClose(): void, onInvalidate(): void }) {
function CreateProjectDialog(props: { open: boolean, onClose(): void }) {
const formRef = useRef<HTMLFormElement>(null);
const formId = useId();
const [isCreating, setIsCreating] = useState(false);
@ -125,7 +119,6 @@ function CreateProjectDialog(props: { open: boolean, onClose(): void, onInvalida
description: `${formData.get('description')}`,
});
props.onInvalidate();
props.onClose();
} finally {
setIsCreating(false);

View File

@ -57,6 +57,9 @@ export const GET = smartRouteHandler(async (req: NextRequest, options: { params:
if (!provider) {
throw new StatusError(StatusError.NotFound, "Provider not found");
}
if (!provider.enabled) {
throw new StatusError(StatusError.NotFound, "Provider not enabled");
}
const innerCodeVerifier = generators.codeVerifier();
const innerState = generators.state();

View File

@ -73,10 +73,12 @@ export const GET = smartRouteHandler(async (req: NextRequest, options: { params:
}
const provider = project.evaluatedConfig.oauthProviders.find((p) => p.id === providerId);
if (!provider) {
throw new StatusError(StatusError.NotFound, "Provider not found");
}
if (!provider.enabled) {
throw new StatusError(StatusError.NotFound, "Provider not enabled");
}
const userInfo = await getAuthorizationCallback(
provider,

View File

@ -6,7 +6,7 @@ import { checkApiKeySet, publishableClientKeyHeaderSchema, superSecretAdminKeyHe
import { getProject, isProjectAdmin, updateProject } from "@/lib/projects";
import { ClientProjectJson, SharedProvider, StandardProvider, sharedProviders, standardProviders } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { ProjectIdOrKeyInvalidErrorCode, KnownError } from "@stackframe/stack-shared/dist/utils/types";
import { OauthProviderUpdateOptions, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface";
import { ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface";
const putOrGetSchema = yup.object({
headers: yup.object({
@ -16,7 +16,6 @@ const putOrGetSchema = yup.object({
"x-stack-project-id": yup.string().required(),
}).required(),
body: yup.object({
showDisabledOauth: yup.boolean().optional(),
isProductionMode: yup.boolean().optional(),
config: yup.object({
domains: yup.array(yup.object({
@ -50,7 +49,7 @@ const handler = smartRouteHandler(async (req: NextRequest, options: { params: {
body,
} = await parseRequest(req, putOrGetSchema);
const { showDisabledOauth, ...update } = body ?? {};
const { ...update } = body ?? {};
const pkValid = await checkApiKeySet(projectId, { publishableClientKey });
const asValid = await isProjectAdmin(projectId, adminAccessToken);
@ -96,16 +95,12 @@ const handler = smartRouteHandler(async (req: NextRequest, options: { params: {
const project = await updateProject(
projectId,
typedUpdate,
showDisabledOauth,
);
return NextResponse.json(project);
} else if (asValid || pkValid) {
if (Object.entries(update).length !== 0) {
throw new StatusError(StatusError.Forbidden, "Can't update project with only publishable client key");
}
if (showDisabledOauth) {
throw new StatusError(StatusError.Forbidden, "Can't show disabled oauth providers with only publishable client key");
}
const project = await getProject(projectId);
if (!project) {
@ -117,6 +112,7 @@ const handler = smartRouteHandler(async (req: NextRequest, options: { params: {
oauthProviders: project.evaluatedConfig.oauthProviders.map(
(provider) => ({
id: provider.id,
enabled: provider.enabled,
}),
),
};

View File

@ -1,5 +1,5 @@
import * as yup from 'yup';
import { ApiKeySetFirstViewJson, ApiKeySetSummaryJson } from '@stackframe/stack-shared';
import { ApiKeySetFirstViewJson, ApiKeySetJson } from '@stackframe/stack-shared';
import { ApiKeySet } from '@prisma/client';
import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto';
import * as crypto from 'node:crypto';
@ -28,7 +28,7 @@ export async function getApiKeySet(
| { publishableClientKey: string }
| { secretServerKey: string }
| { superSecretAdminKey: string },
): Promise<ApiKeySetSummaryJson | null> {
): Promise<ApiKeySetJson | null> {
const where = typeof whereOrId === 'string'
? {
projectId_id: {
@ -51,7 +51,7 @@ export async function getApiKeySet(
export async function listApiKeySets(
projectId: string,
): Promise<ApiKeySetSummaryJson[]> {
): Promise<ApiKeySetJson[]> {
const sets = await prismaClient.apiKeySet.findMany({
where: {
projectId,
@ -121,7 +121,7 @@ export async function revokeApiKeySet(projectId: string, apiKeyId: string) {
return createSummaryFromDbType(set);
}
function createSummaryFromDbType(set: ApiKeySet): ApiKeySetSummaryJson {
function createSummaryFromDbType(set: ApiKeySet): ApiKeySetJson {
return {
id: set.id,
description: set.description,

View File

@ -209,14 +209,13 @@ export async function createProject(
return projectJsonFromDbType(project);
}
export async function getProject(projectId: string, showDisabledOauth: boolean = false): Promise<ProjectJson | null> {
return await updateProject(projectId, {}, showDisabledOauth);
export async function getProject(projectId: string): Promise<ProjectJson | null> {
return await updateProject(projectId, {});
}
export async function updateProject(
projectId: string,
options: ProjectUpdateOptions,
showDisabledOauth: boolean = false
): Promise<ProjectJson | null> {
// TODO: Validate production mode consistency
const transaction = [];
@ -393,10 +392,10 @@ export async function updateProject(
return null;
}
return projectJsonFromDbType(updatedProject, showDisabledOauth);
return projectJsonFromDbType(updatedProject);
}
function projectJsonFromDbType(project: ProjectDB, showDisabledOauth: boolean = false): ProjectJson {
function projectJsonFromDbType(project: ProjectDB): ProjectJson {
let emailConfig: EmailConfigJson | undefined;
const emailServiceConfig = project.config.emailServiceConfig;
if (emailServiceConfig) {
@ -435,9 +434,6 @@ function projectJsonFromDbType(project: ProjectDB, showDisabledOauth: boolean =
handlerPath: domain.handlerPath,
})),
oauthProviders: project.config.oauthProviderConfigs.flatMap((provider): OauthProviderConfigJson[] => {
if (!showDisabledOauth && !provider.enabled) {
return [];
}
if (provider.proxiedOauthConfig) {
return [{
id: provider.id,

View File

@ -14,11 +14,8 @@ export {
} from "./interface/serverInterface";
export {
StackAdminInterface,
ApiKeySetBase,
ApiKeySetBaseJson,
ApiKeySetFirstView,
ApiKeySetFirstViewJson,
ApiKeySetSummary,
ApiKeySetSummaryJson,
ApiKeySetJson,
} from "./interface/adminInterface";
export { fetchTokenPrefix } from "./helpers/fetch-token";

View File

@ -1,7 +1,5 @@
import { ServerAuthApplicationOptions, StackServerInterface } from "./serverInterface";
import { ProjectJson, SharedProvider, StandardProvider, TokenStore } from "./clientInterface";
import { throwErr } from "../utils/errors";
import { ReadonlyJson } from "../utils/json";
import { ProjectJson, ReadonlyTokenStore, SharedProvider, StandardProvider, TokenStore } from "./clientInterface";
export type AdminAuthApplicationOptions = Readonly<
ServerAuthApplicationOptions &
@ -10,7 +8,7 @@ export type AdminAuthApplicationOptions = Readonly<
superSecretAdminKey: string,
}
| {
internalAdminAccessToken: string,
projectOwnerTokens: ReadonlyTokenStore,
}
)
>
@ -30,7 +28,7 @@ export type OauthProviderUpdateOptions = {
}
)
export type ProjectUpdateOptions = Readonly<{
export type ProjectUpdateOptions = {
isProductionMode?: boolean,
config?: {
domains?: {
@ -41,69 +39,41 @@ export type ProjectUpdateOptions = Readonly<{
credentialEnabled?: boolean,
allowLocalhost?: boolean,
},
}>
};
export type ApiKeySetBase = Readonly<{
id: string,
description: string,
expiresAt: Date,
manuallyRevokedAt: Date | null,
createdAt: Date,
isValid(): boolean,
whyInvalid(): "expired" | "manually-revoked" | null,
}>
export type ApiKeySetBaseJson = Readonly<{
export type ApiKeySetBaseJson = {
id: string,
description: string,
expiresAtMillis: number,
manuallyRevokedAtMillis: number | null,
createdAtMillis: number,
}>
};
export type ApiKeySetFirstView = Readonly<
ApiKeySetBase & {
publishableClientKey?: string,
secretServerKey?: string,
superSecretAdminKey?: string,
}
>
export type ApiKeySetFirstViewJson = ApiKeySetBaseJson & {
publishableClientKey?: string,
secretServerKey?: string,
superSecretAdminKey?: string,
};
export type ApiKeySetFirstViewJson = Readonly<
ApiKeySetBaseJson & {
publishableClientKey?: string,
secretServerKey?: string,
superSecretAdminKey?: string,
}
>
export type ApiKeySetJson = ApiKeySetBaseJson & {
publishableClientKey: null | {
lastFour: string,
},
secretServerKey: null | {
lastFour: string,
},
superSecretAdminKey: null | {
lastFour: string,
},
};
export type ApiKeySetSummary = Readonly<
ApiKeySetBase & {
publishableClientKey: null | {
lastFour: string,
},
secretServerKey: null | {
lastFour: string,
},
superSecretAdminKey: null | {
lastFour: string,
},
}
>
export type ApiKeySetSummaryJson = Readonly<
ApiKeySetBaseJson & {
publishableClientKey: null | {
lastFour: string,
},
secretServerKey: null | {
lastFour: string,
},
superSecretAdminKey: null | {
lastFour: string,
},
}
>
export type ApiKeySetCreateOptions = {
hasPublishableClientKey: boolean,
hasSecretServerKey: boolean,
hasSuperSecretAdminKey: boolean,
expiresAt: Date,
description: string,
};
export class StackAdminInterface extends StackServerInterface {
constructor(public readonly options: AdminAuthApplicationOptions) {
@ -155,14 +125,8 @@ export class StackAdminInterface extends StackServerInterface {
}
async createApiKeySet(
options: {
hasPublishableClientKey: boolean,
hasSecretServerKey: boolean,
hasSuperSecretAdminKey: boolean,
expiresAt: Date,
description: string,
},
): Promise<ApiKeySetFirstView> {
options: ApiKeySetCreateOptions,
): Promise<ApiKeySetFirstViewJson> {
const response = await this.sendServerRequest(
"/api-keys",
{
@ -174,13 +138,13 @@ export class StackAdminInterface extends StackServerInterface {
},
null,
);
return createApiKeySetFirstViewFromJson(await response.json());
return await response.json();
}
async listApiKeySets(): Promise<ApiKeySetSummary[]> {
async listApiKeySets(): Promise<ApiKeySetJson[]> {
const response = await this.sendAdminRequest("/api-keys", {}, null);
const json = await response.json();
return json.map((k: ApiKeySetSummaryJson) => createApiKeySetSummaryFromJson(k));
return json.map((k: ApiKeySetJson) => k);
}
async revokeApiKeySetById(id: string) {
@ -198,44 +162,9 @@ export class StackAdminInterface extends StackServerInterface {
);
}
async getApiKeySet(id: string, tokenStore: TokenStore): Promise<ApiKeySetSummary> {
async getApiKeySet(id: string, tokenStore: TokenStore): Promise<ApiKeySetJson> {
const response = await this.sendAdminRequest(`/api-keys/${id}`, {}, tokenStore);
return await response.json();
}
}
function createApiKeySetBaseFromJson(data: ApiKeySetBaseJson): ApiKeySetBase {
return {
id: data.id,
description: data.description,
expiresAt: new Date(data.expiresAtMillis),
manuallyRevokedAt: data.manuallyRevokedAtMillis ? new Date(data.manuallyRevokedAtMillis) : null,
createdAt: new Date(data.createdAtMillis),
isValid() {
return this.whyInvalid() === null;
},
whyInvalid() {
if (this.expiresAt.getTime() < Date.now()) return "expired";
if (this.manuallyRevokedAt) return "manually-revoked";
return null;
},
};
}
function createApiKeySetSummaryFromJson(data: ApiKeySetSummaryJson): ApiKeySetSummary {
return {
...createApiKeySetBaseFromJson(data),
publishableClientKey: data.publishableClientKey ? { lastFour: data.publishableClientKey.lastFour } : null,
secretServerKey: data.secretServerKey ? { lastFour: data.secretServerKey.lastFour } : null,
superSecretAdminKey: data.superSecretAdminKey ? { lastFour: data.superSecretAdminKey.lastFour } : null,
};
}
function createApiKeySetFirstViewFromJson(data: ApiKeySetFirstViewJson): ApiKeySetFirstView {
return {
...createApiKeySetBaseFromJson(data),
publishableClientKey: data.publishableClientKey,
secretServerKey: data.secretServerKey,
superSecretAdminKey: data.superSecretAdminKey,
};
}

View File

@ -15,11 +15,11 @@ import {
PasswordResetLinkErrorCodes,
PasswordResetLinkErrorCode
} from "../utils/types";
import { Result } from "../utils/results";
import { AsyncResult, Result } from "../utils/results";
import { ReadonlyJson, parseJson } from '../utils/json';
import { AsyncCache } from '../utils/caches';
import { typedAssign } from '../utils/objects';
import { AsyncStore } from '../utils/stores';
import { AsyncStore, ReadonlyAsyncStore } from '../utils/stores';
import { neverResolve, runAsynchronously } from '../utils/promises';
export type UserCustomizableJson = {
@ -43,6 +43,7 @@ export type ClientProjectJson = {
readonly credentialEnabled: boolean,
readonly oauthProviders: readonly {
id: string,
enabled: boolean,
}[],
};
@ -52,7 +53,7 @@ export type ClientInterfaceOptions = {
} & ({
readonly publishableClientKey: string,
} | {
readonly internalAdminAccessToken: string,
readonly projectOwnerTokens: ReadonlyTokenStore,
});
export type SharedProvider = "shared-github" | "shared-google" | "shared-facebook" | "shared-microsoft";
@ -84,6 +85,7 @@ function getSessionCookieName(projectId: string) {
return "__stack-token-" + crypto.createHash("sha256").update(projectId).digest("hex");
}
export type ReadonlyTokenStore = ReadonlyAsyncStore<TokenObject>;
export type TokenStore = AsyncStore<TokenObject>;
export type TokenObject = Readonly<{
@ -142,6 +144,11 @@ export type DomainConfigJson = {
handlerPath: string,
}
export type ProductionModeError = {
errorMessage: string,
fixUrlRelative: string,
};
export class StackClientInterface {
constructor(public readonly options: ClientInterfaceOptions) {
// nothing here
@ -238,25 +245,13 @@ export class StackClientInterface {
});
try {
return await Result.orThrowAsync(
Result.retry(
() => this.sendClientRequestInner(path, requestOptions, tokenStore!),
5,
{ exponentialDelayBase: 1000 },
)
);
} catch (error: any) {
// TODO this is a hack. Occurs when the admin access token is invalid, or expired. Has plenty of weird side effects so we should replace this
if ("internalAdminAccessToken" in this.options && error?.message?.includes?.("Invalid API key") && typeof window !== "undefined") {
alert("Your session has expired. The page will now reload." + (process.env.NODE_ENV == "development" ? "\n\nThis is a hack and we should probably fix this at some point." : ""));
window.location.href = "/";
document.body.innerHTML = "Reloading...";
await neverResolve();
}
throw error;
}
return await Result.orThrowAsync(
Result.retry(
() => this.sendClientRequestInner(path, requestOptions, tokenStore!),
5,
{ exponentialDelayBase: 1000 },
)
);
}
protected async sendClientRequestAndCatchKnownError<E>(
@ -302,8 +297,8 @@ export class StackClientInterface {
...'publishableClientKey' in this.options ? {
"x-stack-publishable-client-key": this.options.publishableClientKey,
} : {},
...'internalAdminAccessToken' in this.options ? {
"x-stack-admin-access-token": this.options.internalAdminAccessToken,
...'projectOwnerTokens' in this.options ? {
"x-stack-admin-access-token": AsyncResult.or(this.options.projectOwnerTokens?.get(), null)?.accessToken ?? "",
} : {},
...options.headers,
},
@ -672,8 +667,8 @@ export class StackClientInterface {
}
}
export function getProductionModeErrors(project: ProjectJson): { errorMessage: string, fixUrlRelative: string }[] {
const errors: { errorMessage: string, fixUrlRelative: string }[] = [];
export function getProductionModeErrors(project: ProjectJson): ProductionModeError[] {
const errors: ProductionModeError[] = [];
for (const { domain, handlerPath } of project.evaluatedConfig.domains) {
// TODO: check if handlerPath is valid

View File

@ -3,7 +3,8 @@ import {
UserCustomizableJson,
UserJson,
TokenStore,
StackClientInterface,
StackClientInterface,
ReadonlyTokenStore,
} from "./clientInterface";
import { Result } from "../utils/results";
import { AsyncCache } from "../utils/caches";
@ -28,7 +29,7 @@ export type ServerAuthApplicationOptions = (
readonly secretServerKey: string,
}
| {
readonly internalAdminAccessToken: string,
readonly projectOwnerTokens: ReadonlyTokenStore,
}
)
);

View File

@ -85,7 +85,7 @@ class AsyncValueCache<T> {
this._store = new AsyncStore();
this._rateLimitOptions = {
concurrency: 1,
debounceMs: 3_000,
debounceMs: 300,
...filterUndefined(_options.rateLimiter ?? {}),
};

View File

@ -1,11 +1,15 @@
'use client';
import MessageCard from "../elements/MessageCard";
import { useStackApp } from "..";
import { StackClientApp, useStackApp } from "..";
import { use } from "react";
import PasswordResetInner from "../elements/PasswordResetInner";
import { PasswordResetLinkExpiredErrorCode, PasswordResetLinkInvalidErrorCode, PasswordResetLinkUsedErrorCode } from "@stackframe/stack-shared/dist/utils/types";
import { useStrictMemo } from "@stackframe/stack-shared/dist/hooks/use-strict-memo";
import { cacheFunction } from "@stackframe/stack-shared/dist/utils/caches";
const cachedVerifyPasswordResetCode = cacheFunction(async (stackApp: StackClientApp<true>, code: string) => {
return await stackApp.verifyPasswordResetCode(code);
});
export default function PasswordReset({
searchParams,
@ -39,10 +43,7 @@ export default function PasswordReset({
return invalidJsx;
}
const errorCdoePromise = useStrictMemo(() => {
return stackApp.verifyPasswordResetCode(code);
}, [code]);
const errorCode = use(errorCdoePromise);
const errorCode = use(cachedVerifyPasswordResetCode(stackApp, code));
switch (errorCode) {
case PasswordResetLinkInvalidErrorCode: {

View File

@ -13,8 +13,8 @@ export default function OauthGroup({
return (
<div className="wl_space-y-4 wl_flex wl_flex-col wl_items-stretch">
{project.oauthProviders.map(({ id }) => (
<OauthButton key={id} provider={id} type={type} redirectUrl={redirectUrl} />
{project.oauthProviders.filter(p => p.enabled).map(p => (
<OauthButton key={p.id} provider={p.id} type={type} redirectUrl={redirectUrl} />
))}
</div>
);

View File

@ -6,7 +6,7 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { AsyncResult, Result } from "@stackframe/stack-shared/dist/utils/results";
import { suspendIfSsr } from "@stackframe/stack-shared/dist/utils/react";
import { AsyncStore } from "@stackframe/stack-shared/dist/utils/stores";
import { ClientProjectJson, UserCustomizableJson, UserJson, TokenObject, TokenStore, ProjectJson, EmailConfigJson, DomainConfigJson } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { ClientProjectJson, UserCustomizableJson, UserJson, TokenObject, TokenStore, ProjectJson, EmailConfigJson, DomainConfigJson, ReadonlyTokenStore, getProductionModeErrors, ProductionModeError } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { isClient } from "../utils/next";
import { callOauthCallback, signInWithCredential, signInWithOauth, signUpWithCredential } from "./auth";
import { RedirectType, redirect, useRouter } from "next/navigation";
@ -14,8 +14,9 @@ import { ReadonlyJson } from "../utils/types";
import { constructRedirectUrl } from "../utils/url";
import { EmailVerificationLinkErrorCode, PasswordResetLinkErrorCode, SignInErrorCode, SignUpErrorCode } from "@stackframe/stack-shared/dist/utils/types";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { neverResolve, resolved, runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { neverResolve, resolved } from "@stackframe/stack-shared/dist/utils/promises";
import { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches";
import { ApiKeySetBaseJson, ApiKeySetCreateOptions, ApiKeySetFirstViewJson, ApiKeySetJson, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface";
export type TokenStoreOptions<HasTokenStore extends boolean = boolean> =
@ -87,9 +88,20 @@ export type StackServerAppConstructorOptions<HasTokenStore extends boolean, Proj
secretServerKey?: string,
};
export type StackAdminAppConstructorOptions<HasTokenStore extends boolean, ProjectId extends string> = StackServerAppConstructorOptions<HasTokenStore, ProjectId> & {
superSecretAdminKey?: string,
};
export type StackAdminAppConstructorOptions<HasTokenStore extends boolean, ProjectId extends string> = (
| (
& StackServerAppConstructorOptions<HasTokenStore, ProjectId>
& {
superSecretAdminKey?: string,
}
)
| (
& Omit<StackServerAppConstructorOptions<HasTokenStore, ProjectId>, "publishableClientKey" | "secretServerKey">
& {
projectOwnerTokens: ReadonlyTokenStore,
}
)
);
export type StackClientAppJson<HasTokenStore extends boolean, ProjectId extends string> = StackClientAppConstructorOptions<HasTokenStore, ProjectId> & {
uniqueIdentifier: string,
@ -218,6 +230,7 @@ const createCacheByTokenStore = <D extends any[], T>(fetcher: (tokenStore: Token
async ([tokenStore, ...extraDependencies]) => await fetcher(tokenStore, extraDependencies),
{
onSubscribe: ([tokenStore], refresh) => {
// TODO find a *clean* way to not refresh when the token change was made inside the fetcher (for example due to expired access token)
const handlerObj = tokenStore.onChange((newValue, oldValue) => {
if (JSON.stringify(newValue) === JSON.stringify(oldValue)) return;
refresh();
@ -242,6 +255,9 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
private readonly _currentProjectCache = createCache(async () => {
return Result.orThrow(await this._interface.getClientProject());
});
private readonly _ownedProjectsCache = createCacheByTokenStore(async (tokenStore) => {
return await this._interface.listProjects(tokenStore);
});
constructor(options:
& {
@ -312,12 +328,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
const app = this;
const res: CurrentUser = {
...this._userFromJson(json),
get accessToken() {
return AsyncResult.or(tokenStore.get(), null)?.accessToken ?? null;
},
get refreshToken() {
return AsyncResult.or(tokenStore.get(), null)?.refreshToken ?? null;
},
tokenStore,
update(update) {
return app._updateUser(update, tokenStore);
},
@ -342,7 +353,11 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
};
}
protected _projectAdminFromJson(data: ProjectJson): Project {
protected _projectAdminFromJson(data: ProjectJson, adminInterface: StackAdminInterface, onRefresh: () => Promise<void>): Project {
if (data.id !== adminInterface.projectId) {
throw new Error(`The project ID of the provided project JSON (${data.id}) does not match the project ID of the app (${adminInterface.projectId})! This is a Stack bug.`);
}
return {
id: data.id,
displayName: data.displayName,
@ -352,14 +367,36 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
isProductionMode: data.isProductionMode,
evaluatedConfig: {
id: data.evaluatedConfig.id,
credentialEnabled: data.evaluatedConfig.credentialEnabled,
allowLocalhost: data.evaluatedConfig.allowLocalhost,
oauthProviders: data.evaluatedConfig.oauthProviders,
emailConfig: data.evaluatedConfig.emailConfig,
domains: data.evaluatedConfig.domains,
},
async update(update: ProjectUpdateOptions) {
await adminInterface.updateProject(update);
await onRefresh();
},
toJson() {
return data;
},
getProductionModeErrors() {
return getProductionModeErrors(this.toJson());
},
};
}
protected _createAdminInterface(forProjectId: string, tokenStore: TokenStore): StackAdminInterface {
return new StackAdminInterface({
baseUrl: this._interface.options.baseUrl,
projectId: forProjectId,
projectOwnerTokens: tokenStore,
});
}
get projectId(): ProjectId {
return this._interface.projectId as ProjectId;
}
@ -542,25 +579,66 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async listOwnedProjects(): Promise<Project[]> {
this._ensureInternalProject();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const json = await this._interface.listProjects(tokenStore);
return json.map((j) => this._projectAdminFromJson(j));
const json = await this._ownedProjectsCache.getOrWait([tokenStore], "never");
return json.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
));
}
useOwnedProjects(): Project[] {
this._ensureInternalProject();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const json = useCache(this._ownedProjectsCache, [tokenStore]);
return json.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
));
}
onOwnedProjectsChange(callback: (projects: Project[]) => void) {
this._ensureInternalProject();
const tokenStore = getTokenStore(this._tokenStoreOptions);
return this._ownedProjectsCache.onChange([tokenStore], (projects) => {
callback(projects.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
)));
});
}
async createProject(newProject: Pick<Project, "displayName" | "description">): Promise<Project> {
this._ensureInternalProject();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const json = await this._interface.createProject(newProject, tokenStore);
return this._projectAdminFromJson(json);
const res = this._projectAdminFromJson(
json,
this._createAdminInterface(json.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
);
await this._refreshOwnedProjects(tokenStore);
return res;
}
protected async _refreshUser(tokenStore: TokenStore) {
await this._currentUserCache.refresh([tokenStore]);
}
protected async _refreshUsers() {
// nothing yet
}
protected async _refreshProject() {
await this._currentProjectCache.refresh([]);
}
protected async _refreshOwnedProjects(tokenStore: TokenStore) {
await this._ownedProjectsCache.refresh([tokenStore]);
}
static get [stackAppInternalsSymbol]() {
return {
fromClientJson: <HasTokenStore extends boolean, ProjectId extends string>(
@ -612,6 +690,9 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
const user = await this._interface.getServerUserByToken(tokenStore);
return Result.or(user, null);
});
private readonly _serverUsersCache = createCache(async () => {
return await this._interface.listUsers();
});
constructor(options:
| StackServerAppConstructorOptions<HasTokenStore, ProjectId>
@ -650,15 +731,17 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
...this._userFromJson(json),
serverMetadata: json.serverMetadata,
async delete() {
await app._interface.deleteServerUser(this.id);
const res = await app._interface.deleteServerUser(this.id);
await app._refreshUsers();
return res;
},
async update(update: Partial<ServerUserCustomizableJson>) {
await app._interface.setServerUserCustomizableData(this.id, update);
const res = await app._interface.setServerUserCustomizableData(this.id, update);
await app._refreshUsers();
return res;
},
getClientUser() {
return {
...app._userFromJson(json),
};
return app._userFromJson(json);
},
toJson() {
return app._serverUserToJson(this);
@ -674,12 +757,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
const nonCurrentServerUser = this._serverUserFromJson(json);
const res: CurrentServerUser = {
...nonCurrentServerUser,
get accessToken() {
return AsyncResult.or(tokenStore.get(), null)?.accessToken ?? null;
},
get refreshToken() {
return AsyncResult.or(tokenStore.get(), null)?.refreshToken ?? null;
},
tokenStore,
async delete() {
const res = await nonCurrentServerUser.delete();
await app._refreshUser(tokenStore);
@ -694,22 +772,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return app._signOut(tokenStore, redirectUrl);
},
getClientUser() {
const currentServerUser = this;
return {
...app._userFromJson(json),
get accessToken() {
return currentServerUser.accessToken;
},
get refreshToken() {
return currentServerUser.refreshToken;
},
update(update: Partial<ServerUserCustomizableJson>) {
return currentServerUser.update(update);
},
signOut(redirectUrl?: string) {
return currentServerUser.signOut(redirectUrl);
},
};
return app._currentUserFromJson(json, tokenStore);
},
};
Object.freeze(res);
@ -758,12 +821,35 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
});
}
async listServerUsers(): Promise<ServerUser[]> {
const json = await this._serverUsersCache.getOrWait([], "never");
return json.map((j) => this._serverUserFromJson(j));
}
useServerUsers(): ServerUser[] {
const json = useCache(this._serverUsersCache, []);
return json.map((j) => this._serverUserFromJson(j));
}
onServerUsersChange(callback: (users: ServerUser[]) => void) {
return this._serverUsersCache.onChange([], (users) => {
callback(users.map((j) => this._serverUserFromJson(j)));
});
}
protected override async _refreshUser(tokenStore: TokenStore) {
await Promise.all([
super._refreshUser(tokenStore),
this._currentServerUserCache.refresh([tokenStore]),
]);
}
protected override async _refreshUsers() {
await Promise.all([
super._refreshUsers(),
this._serverUsersCache.refresh([]),
]);
}
}
class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string> extends _StackServerAppImpl<HasTokenStore, ProjectId>
@ -773,46 +859,133 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
private readonly _adminProjectCache = createCache(async () => {
return await this._interface.getProject();
});
private readonly _apiKeySetsCache = createCache(async () => {
return await this._interface.listApiKeySets();
});
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>) {
super({
interface: new StackAdminInterface({
baseUrl: options.baseUrl ?? getDefaultBaseUrl(),
projectId: options.projectId ?? getDefaultProjectId(),
publishableClientKey: options.publishableClientKey ?? getDefaultPublishableClientKey(),
secretServerKey: options.secretServerKey ?? getDefaultSecretServerKey(),
superSecretAdminKey: options.superSecretAdminKey ?? getDefaultSuperSecretAdminKey(),
..."projectOwnerTokens" in options ? {
projectOwnerTokens: options.projectOwnerTokens,
} : {
publishableClientKey: options.publishableClientKey ?? getDefaultPublishableClientKey(),
secretServerKey: options.secretServerKey ?? getDefaultSecretServerKey(),
superSecretAdminKey: options.superSecretAdminKey ?? getDefaultSuperSecretAdminKey(),
},
}),
tokenStore: options.tokenStore,
urls: options.urls,
});
}
protected _createApiKeySetBaseFromJson(data: ApiKeySetBaseJson): ApiKeySetBase {
const app = this;
return {
id: data.id,
description: data.description,
expiresAt: new Date(data.expiresAtMillis),
manuallyRevokedAt: data.manuallyRevokedAtMillis ? new Date(data.manuallyRevokedAtMillis) : null,
createdAt: new Date(data.createdAtMillis),
isValid() {
return this.whyInvalid() === null;
},
whyInvalid() {
if (this.expiresAt.getTime() < Date.now()) return "expired";
if (this.manuallyRevokedAt) return "manually-revoked";
return null;
},
async revoke() {
const res = await app._interface.revokeApiKeySetById(data.id);
await app._refreshApiKeySets();
return res;
}
};
}
protected _createApiKeySetFromJson(data: ApiKeySetJson): ApiKeySet {
return {
...this._createApiKeySetBaseFromJson(data),
publishableClientKey: data.publishableClientKey ? { lastFour: data.publishableClientKey.lastFour } : null,
secretServerKey: data.secretServerKey ? { lastFour: data.secretServerKey.lastFour } : null,
superSecretAdminKey: data.superSecretAdminKey ? { lastFour: data.superSecretAdminKey.lastFour } : null,
};
}
protected _createApiKeySetFirstViewFromJson(data: ApiKeySetFirstViewJson): ApiKeySetFirstView {
return {
...this._createApiKeySetBaseFromJson(data),
publishableClientKey: data.publishableClientKey,
secretServerKey: data.secretServerKey,
superSecretAdminKey: data.superSecretAdminKey,
};
}
async getProjectAdmin(): Promise<Project> {
return this._projectAdminFromJson(await this._adminProjectCache.getOrWait([], "never"));
return this._projectAdminFromJson(
await this._adminProjectCache.getOrWait([], "never"),
this._interface,
() => this._refreshProject()
);
}
useProjectAdmin(): Project {
return this._projectAdminFromJson(useCache(this._adminProjectCache, []));
return this._projectAdminFromJson(
useCache(this._adminProjectCache, []),
this._interface,
() => this._refreshProject()
);
}
onProjectAdminChange(callback: (project: Project) => void) {
return this._adminProjectCache.onChange([], () => {
callback(this._projectAdminFromJson(useCache(this._adminProjectCache, [])));
return this._adminProjectCache.onChange([], (project) => {
callback(this._projectAdminFromJson(
project,
this._interface,
() => this._refreshProject()
));
});
}
async listApiKeySets(): Promise<ApiKeySet[]> {
const json = await this._apiKeySetsCache.getOrWait([], "never");
return json.map((j) => this._createApiKeySetFromJson(j));
}
useApiKeySets(): ApiKeySet[] {
const json = useCache(this._apiKeySetsCache, []);
return json.map((j) => this._createApiKeySetFromJson(j));
}
onApiKeySetsChange(callback: (apiKeySets: ApiKeySet[]) => void) {
return this._apiKeySetsCache.onChange([], (apiKeySets) => {
callback(apiKeySets.map((j) => this._createApiKeySetFromJson(j)));
});
}
async createApiKeySet(options: ApiKeySetCreateOptions): Promise<ApiKeySetFirstView> {
const json = await this._interface.createApiKeySet(options);
await this._refreshApiKeySets();
return this._createApiKeySetFirstViewFromJson(json);
}
protected override async _refreshProject() {
await Promise.all([
super._refreshProject(),
this._adminProjectCache.refresh([]),
]);
}
protected async _refreshApiKeySets() {
await this._apiKeySetsCache.refresh([]);
}
}
type Auth<T, C> = {
readonly accessToken: string | null,
readonly refreshToken: string | null,
readonly tokenStore: ReadonlyTokenStore,
update(this: T, user: Partial<C>): Promise<void>,
signOut(this: T, redirectUrl?: string): Promise<never>,
};
@ -865,21 +1038,58 @@ export type CurrentServerUser = Auth<ServerUser, ServerUserCustomizableJson> & O
getClientUser(this: CurrentServerUser): CurrentUser,
};
export type Project = Readonly<{
id: string,
displayName: string,
description?: string,
createdAt: Date,
userCount: number,
isProductionMode: boolean,
evaluatedConfig: {
id: string,
allowLocalhost: boolean,
oauthProviders: OauthProviderConfig[],
emailConfig?: EmailConfig,
domains: DomainConfig[],
export type Project = {
readonly id: string,
readonly displayName: string,
readonly description?: string,
readonly createdAt: Date,
readonly userCount: number,
readonly isProductionMode: boolean,
readonly evaluatedConfig: {
readonly id: string,
readonly allowLocalhost: boolean,
readonly credentialEnabled: boolean,
readonly oauthProviders: OauthProviderConfig[],
readonly emailConfig?: EmailConfig,
readonly domains: DomainConfig[],
},
}>;
update(this: Project, update: ProjectUpdateOptions): Promise<void>,
toJson(this: Project): ProjectJson,
getProductionModeErrors(this: Project): ProductionModeError[],
};
export type ApiKeySetBase = {
id: string,
description: string,
expiresAt: Date,
manuallyRevokedAt: Date | null,
createdAt: Date,
isValid(): boolean,
whyInvalid(): "expired" | "manually-revoked" | null,
revoke(): Promise<void>,
};
export type ApiKeySetFirstView = ApiKeySetBase & {
publishableClientKey?: string,
secretServerKey?: string,
superSecretAdminKey?: string,
};
export type ApiKeySet = ApiKeySetBase & {
publishableClientKey: null | {
lastFour: string,
},
secretServerKey: null | {
lastFour: string,
},
superSecretAdminKey: null | {
lastFour: string,
},
};
export type EmailConfig = EmailConfigJson;
@ -891,12 +1101,12 @@ export type GetUserOptions = {
or?: 'redirect' | 'throw' | 'return-null',
};
type AsyncStoreProperty<Name extends string, Value> =
& { [key in `get${Capitalize<Name>}`]: () => Promise<Value> }
type AsyncStoreProperty<Name extends string, Value, IsMultiple extends boolean> =
& { [key in `${IsMultiple extends true ? "list" : "get"}${Capitalize<Name>}`]: () => Promise<Value> }
& { [key in `on${Capitalize<Name>}Change`]: (callback: (value: Value) => void) => void }
& { [key in `use${Capitalize<Name>}`]: () => Value }
export type StackClientApp<HasTokenStore extends boolean, ProjectId extends string = string> = (
export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId extends string = string> = (
& {
readonly projectId: ProjectId,
@ -915,7 +1125,7 @@ export type StackClientApp<HasTokenStore extends boolean, ProjectId extends stri
toClientJson(): Promise<StackClientAppJson<HasTokenStore, ProjectId>>,
},
}
& AsyncStoreProperty<"project", ClientProjectJson>
& AsyncStoreProperty<"project", ClientProjectJson, false>
& { [K in `redirectTo${Capitalize<keyof HandlerUrls>}`]: () => Promise<never> }
& (HasTokenStore extends false
? {}
@ -926,13 +1136,15 @@ export type StackClientApp<HasTokenStore extends boolean, ProjectId extends stri
getUser(options: GetUserOptions & { or: 'redirect' }): Promise<CurrentUser>,
getUser(options: GetUserOptions & { or: 'throw' }): Promise<CurrentUser>,
getUser(options?: GetUserOptions): Promise<CurrentUser | null>,
onUserChange: AsyncStoreProperty<"user", CurrentUser | null>["onUserChange"],
onUserChange: AsyncStoreProperty<"user", CurrentUser | null, false>["onUserChange"],
})
& (
ProjectId extends "internal" ? {
listOwnedProjects(): Promise<Project[]>,
createProject(project: Pick<Project, "displayName" | "description">): Promise<Project>,
} : {}
ProjectId extends "internal" ? (
& AsyncStoreProperty<"ownedProjects", Project[], true>
& {
createProject(project: Pick<Project, "displayName" | "description">): Promise<Project>,
}
) : {}
)
);
type StackClientAppConstructor = {
@ -951,9 +1163,10 @@ type StackClientAppConstructor = {
};
export const StackClientApp: StackClientAppConstructor = _StackClientAppImpl;
export type StackServerApp<HasTokenStore extends boolean, ProjectId extends string = string> = (
export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId extends string = string> = (
& StackClientApp<HasTokenStore, ProjectId>
& AsyncStoreProperty<"serverUser", CurrentServerUser | null>
& AsyncStoreProperty<"serverUser", CurrentServerUser | null, false>
& AsyncStoreProperty<"serverUsers", ServerUser[], true>
& {}
);
type StackServerAppConstructor = {
@ -966,9 +1179,13 @@ type StackServerAppConstructor = {
};
export const StackServerApp: StackServerAppConstructor = _StackServerAppImpl;
export type StackAdminApp<HasTokenStore extends boolean, ProjectId extends string = string> = (
export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId extends string = string> = (
& StackServerApp<HasTokenStore, ProjectId>
& AsyncStoreProperty<"projectAdmin", Project>
& AsyncStoreProperty<"projectAdmin", Project, false>
& AsyncStoreProperty<"apiKeySets", ApiKeySet[], true>
& {
createApiKeySet(options: ApiKeySetCreateOptions): Promise<ApiKeySetFirstView>,
}
);
type StackAdminAppConstructor = {
new <