added enabled to oauth config

This commit is contained in:
Zai Shi 2024-03-06 09:02:05 +08:00
parent 378a4b3e62
commit 278a18a1ed
7 changed files with 164 additions and 70 deletions

View File

@ -252,6 +252,8 @@ model OauthProviderConfig {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
enabled Boolean @default(true)
proxiedOauthConfig ProxiedOauthProviderConfig?
standardOauthConfig StandardOauthProviderConfig?
projectUserOauthAccounts ProjectUserOauthAccount[]

View File

@ -15,12 +15,14 @@ export default function ProvidersClient() {
const [invalidationCounter, setInvalidationCounter] = useState(0);
const projectPromise = useStrictMemo(async () => {
return await stackAdminApp.getProject();
return await stackAdminApp.getProject({ showDisabledOauth: true });
}, [stackAdminApp, invalidationCounter]);
const project = use(projectPromise);
const oauthProviders = project.evaluatedConfig.oauthProviders;
console.log(oauthProviders);
return (
<>
<Paragraph h1>
@ -58,11 +60,15 @@ export default function ProvidersClient() {
key={id}
id={id}
provider={provider}
updateProvider={async (provider?: OauthProviderConfigJson) => {
updateProvider={async (provider: OauthProviderConfigJson) => {
const alreadyExist = oauthProviders.some((p) => p.id === id);
const newOauthProviders = oauthProviders.map((p) => p.id === id ? provider : p);
if (!alreadyExist) {
newOauthProviders.push(provider);
}
await stackAdminApp.updateProject({
config: {
oauthProviders: oauthProviders.map((p) => p.id === id ? provider : p).filter((p) => p) as OauthProviderConfigJson[],
},
config: { oauthProviders: newOauthProviders },
});
setInvalidationCounter((counter) => counter + 1);
}}

View File

@ -32,7 +32,7 @@ export type ProviderType = typeof availableProviders[number];
type Props = {
id: ProviderType,
provider?: OauthProviderConfigJson,
updateProvider: (provider?: OauthProviderConfigJson) => Promise<void>,
updateProvider: (provider: OauthProviderConfigJson) => Promise<void>,
};
function toTitle(id: ProviderType) {
@ -46,19 +46,22 @@ function toTitle(id: ProviderType) {
function AccordionSummaryContent(props: Props) {
const title = toTitle(props.id);
const [checked, setChecked] = useState(!!props.provider);
const enabled = props.provider?.enabled;
const [checked, setChecked] = useState(enabled);
return (
<AccordionSummary indicator={props.provider ? undefined : null}>
<AccordionSummary indicator={enabled ? undefined : null}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<SmartSwitch
checked={checked}
sx={{ marginRight: 2 }}
onChange={async (e) => {
e.stopPropagation();
if (checked) {
setChecked(false);
await props.updateProvider();
setChecked(e.target.checked);
if (props.provider) {
await props.updateProvider({ ...props.provider, enabled: e.target.checked });
} else {
await props.updateProvider({ id: props.id, type: toSharedProvider(props.id), enabled: e.target.checked });
}
}}
/>
@ -70,9 +73,9 @@ function AccordionSummaryContent(props: Props) {
}
export function ProviderAccordion(props: Props) {
if (!props.provider) {
if (!props.provider?.enabled) {
return (
<Accordion disabled>
<Accordion>
<AccordionSummaryContent {...props}/>
</Accordion>
);

View File

@ -3,7 +3,7 @@ import * as yup from "yup";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { parseRequest, smartRouteHandler } from "@/lib/route-handlers";
import { checkApiKeySet, publishableClientKeyHeaderSchema, superSecretAdminKeyHeaderSchema } from "@/lib/api-keys";
import { isProjectAdmin, updateProject } from "@/lib/projects";
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";
@ -16,6 +16,7 @@ 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({
@ -25,6 +26,7 @@ const putOrGetSchema = yup.object({
oauthProviders: yup.array(
yup.object({
id: yup.string().required(),
enabled: yup.boolean().required(),
type: yup.string().required(),
clientId: yup.string().optional(),
clientSecret: yup.string().optional(),
@ -47,7 +49,7 @@ const handler = smartRouteHandler(async (req: NextRequest, options: { params: {
body,
} = await parseRequest(req, putOrGetSchema);
const update = body ?? {};
const { showDisabledOauth, ...update } = body ?? {};
const pkValid = await checkApiKeySet(projectId, { publishableClientKey });
const asValid = await isProjectAdmin(projectId, adminAccessToken);
@ -58,12 +60,13 @@ const handler = smartRouteHandler(async (req: NextRequest, options: { params: {
config: update.config && {
domains: update.config.domains,
oauthProviders: update.config.oauthProviders && update.config.oauthProviders.map((provider) => {
if (sharedProviders.includes(provider.type)) {
if (sharedProviders.includes(provider.type as SharedProvider)) {
return {
id: provider.id,
enabled: provider.enabled,
type: provider.type as SharedProvider,
};
} else if (standardProviders.includes(provider.type)) {
} else if (standardProviders.includes(provider.type as StandardProvider)) {
if (!provider.clientId) {
throw new StatusError(StatusError.BadRequest, "Missing clientId");
}
@ -73,6 +76,7 @@ const handler = smartRouteHandler(async (req: NextRequest, options: { params: {
return {
id: provider.id,
enabled: provider.enabled,
type: provider.type as StandardProvider,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
@ -90,13 +94,18 @@ 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");
}
const project = await updateProject(projectId, {});
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) {
throw new Error("Project not found but the API key was valid? Something weird happened");
}
@ -120,4 +129,5 @@ const handler = smartRouteHandler(async (req: NextRequest, options: { params: {
});
export const GET = handler;
export const PUT = handler;
export const POST = handler;
export const DELETE = handler;

View File

@ -7,6 +7,7 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { EmailConfigJson, SharedProvider, StandardProvider, sharedProviders, standardProviders } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import { OauthProviderUpdateOptions, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
function toDBSharedProvider(type: SharedProvider): ProxiedOauthProviderType {
@ -208,13 +209,14 @@ export async function createProject(
return projectJsonFromDbType(project);
}
export async function getProject(projectId: string): Promise<ProjectJson | null> {
return await updateProject(projectId, {});
export async function getProject(projectId: string, showDisabledOauth: boolean = false): Promise<ProjectJson | null> {
return await updateProject(projectId, {}, showDisabledOauth);
}
export async function updateProject(
projectId: string,
options: ProjectUpdateOptions
options: ProjectUpdateOptions,
showDisabledOauth: boolean = false
): Promise<ProjectJson | null> {
// TODO: Validate production mode consistency
const transaction = [];
@ -229,11 +231,6 @@ export async function updateProject(
}
if (options.config?.domains) {
// Fetch current domains
const currentDomains = await prismaClient.projectDomain.findMany({
where: { projectConfigId: project.config.id },
});
const newDomains = options.config.domains;
// delete existing domains
@ -253,46 +250,109 @@ export async function updateProject(
});
}
if (options.config?.oauthProviders) {
transaction.push(prismaClient.oauthProviderConfig.deleteMany({
where: { projectConfigId: project.config.id },
}));
options.config.oauthProviders.forEach(providerConfig => {
if (sharedProviders.includes(providerConfig.type as SharedProvider)) {
transaction.push(prismaClient.oauthProviderConfig.create({
data: {
projectConfigId: project.config.id,
id: providerConfig.id,
proxiedOauthConfig: {
create: {
type: toDBSharedProvider(providerConfig.type as SharedProvider),
},
},
},
}));
} else if (standardProviders.includes(providerConfig.type as StandardProvider)) {
// make typescript happy
const typedProviderConfig = providerConfig as OauthProviderUpdateOptions & { type: StandardProvider };
transaction.push(prismaClient.oauthProviderConfig.create({
data: {
projectConfigId: project.config.id,
id: providerConfig.id,
standardOauthConfig: {
create: {
type: toDBStandardProvider(providerConfig.type as StandardProvider),
clientId: typedProviderConfig.clientId,
clientSecret: typedProviderConfig.clientSecret,
tenantId: typedProviderConfig.tenantId,
},
},
},
}));
} else {
console.error(`Invalid provider type '${providerConfig.type}'`);
const oauthProvidersUpdate = options.config?.oauthProviders;
if (oauthProvidersUpdate) {
const oldProviders = project.config.oauthProviderConfigs;
const providerMap = new Map(oldProviders.map((provider) => [
provider.id,
{
providerUpdate: oauthProvidersUpdate.find((p) => p.id === provider.id) ?? throwErr(`Missing provider update for provider '${provider.id}'`),
oldProvider: provider,
}
});
]));
const newProviders = oauthProvidersUpdate.map((providerUpdate) => ({
id: providerUpdate.id,
update: providerUpdate
})).filter(({ id }) => !providerMap.has(id));
// Update existing providers
for (const [id, { providerUpdate, oldProvider }] of providerMap) {
let providerConfigUpdate;
if (sharedProviders.includes(providerUpdate.type as SharedProvider)) {
providerConfigUpdate = {
proxiedOauthConfig: {
update: {
type: toDBSharedProvider(providerUpdate.type as SharedProvider),
},
},
};
if (oldProvider.standardOauthConfig) {
transaction.push(prismaClient.standardOauthProviderConfig.delete({
where: { projectConfigId_id: { projectConfigId: project.config.id, id } },
}));
}
} else if (standardProviders.includes(providerUpdate.type as StandardProvider)) {
const typedProviderConfig = providerUpdate as OauthProviderUpdateOptions & { type: StandardProvider };
providerConfigUpdate = {
standardOauthConfig: {
update: {
type: toDBStandardProvider(providerUpdate.type as StandardProvider),
clientId: typedProviderConfig.clientId,
clientSecret: typedProviderConfig.clientSecret,
tenantId: typedProviderConfig.tenantId,
},
},
};
if (oldProvider.proxiedOauthConfig) {
transaction.push(prismaClient.proxiedOauthProviderConfig.delete({
where: { projectConfigId_id: { projectConfigId: project.config.id, id } },
}));
}
} else {
console.error(`Invalid provider type '${providerUpdate.type}'`);
}
transaction.push(prismaClient.oauthProviderConfig.update({
where: { projectConfigId_id: { projectConfigId: project.config.id, id } },
data: {
enabled: providerUpdate.enabled,
...providerConfigUpdate,
},
}));
}
// Create new providers
for (const provider of newProviders) {
let providerConfigData;
if (sharedProviders.includes(provider.update.type as SharedProvider)) {
providerConfigData = {
proxiedOauthConfig: {
create: {
type: toDBSharedProvider(provider.update.type as SharedProvider),
},
},
};
} else if (standardProviders.includes(provider.update.type as StandardProvider)) {
const typedProviderConfig = provider.update as OauthProviderUpdateOptions & { type: StandardProvider };
providerConfigData = {
standardOauthConfig: {
create: {
type: toDBStandardProvider(provider.update.type as StandardProvider),
clientId: typedProviderConfig.clientId,
clientSecret: typedProviderConfig.clientSecret,
tenantId: typedProviderConfig.tenantId,
},
},
};
} else {
console.error(`Invalid provider type '${provider.update.type}'`);
}
transaction.push(prismaClient.oauthProviderConfig.create({
data: {
id: provider.id,
projectConfigId: project.config.id,
enabled: provider.update.enabled,
...providerConfigData,
},
}));
}
}
if (options.config?.credentialEnabled !== undefined) {
@ -311,7 +371,7 @@ export async function updateProject(
}));
}
const result = await prismaClient.$transaction(transaction);
await prismaClient.$transaction(transaction);
const updatedProject = await prismaClient.project.findUnique({
where: { id: projectId },
@ -322,10 +382,10 @@ export async function updateProject(
return null;
}
return projectJsonFromDbType(updatedProject);
return projectJsonFromDbType(updatedProject, showDisabledOauth);
}
function projectJsonFromDbType(project: ProjectDB): ProjectJson {
function projectJsonFromDbType(project: ProjectDB, showDisabledOauth: boolean = false): ProjectJson {
let emailConfig: EmailConfigJson | undefined;
const emailServiceConfig = project.config.emailServiceConfig;
if (emailServiceConfig) {
@ -364,15 +424,20 @@ function projectJsonFromDbType(project: ProjectDB): ProjectJson {
handlerPath: domain.handlerPath,
})),
oauthProviders: project.config.oauthProviderConfigs.flatMap((provider): OauthProviderConfigJson[] => {
if (!showDisabledOauth && !provider.enabled) {
return [];
}
if (provider.proxiedOauthConfig) {
return [{
id: provider.id,
enabled: provider.enabled,
type: fromDBSharedProvider(provider.proxiedOauthConfig.type),
}];
}
if (provider.standardOauthConfig) {
return [{
id: provider.id,
enabled: provider.enabled,
type: fromDBStandardProvider(provider.standardOauthConfig.type),
clientId: provider.standardOauthConfig.clientId,
clientSecret: provider.standardOauthConfig.clientSecret,

View File

@ -18,6 +18,7 @@ export type AdminAuthApplicationOptions = Readonly<
export type OauthProviderUpdateOptions = {
id: string,
enabled: boolean,
} & (
| {
type: SharedProvider,
@ -136,10 +137,16 @@ export class StackAdminInterface extends StackServerInterface {
]);
}
async getProject(): Promise<ProjectJson> {
async getProject(options?: { showDisabledOauth?: boolean }): Promise<ProjectJson> {
const response = await this.sendAdminRequest(
"/projects/" + encodeURIComponent(this.projectId),
{},
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(options || {}),
},
null,
);
return await response.json();

View File

@ -110,6 +110,7 @@ export type ProjectJson = Readonly<{
export type OauthProviderConfigJson = {
id: string,
enabled: boolean,
} & (
| { type: SharedProvider }
| {