stack/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
2025-07-31 16:04:41 -07:00

515 lines
22 KiB
TypeScript

import { StackAdminInterface } from "@stackframe/stack-shared";
import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode";
import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface";
import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates";
import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { pick } from "@stackframe/stack-shared/dist/utils/objects";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import { AdminSentEmail } from "../..";
import { EmailConfig, stackAppInternalsSymbol } from "../../common";
import { AdminEmailTemplate } from "../../email-templates";
import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys";
import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions";
import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, adminProjectUpdateOptionsToCrud } from "../../projects";
import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app";
import { clientVersion, createCache, getBaseUrl, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey } from "./common";
import { _StackServerAppImplIncomplete } from "./server-app-impl";
import { EnvironmentConfigOverrideOverride, OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema";
import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface";
import { ConfigCrud } from "@stackframe/stack-shared/dist/interface/crud/config";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, ProjectId extends string> extends _StackServerAppImplIncomplete<HasTokenStore, ProjectId> implements StackAdminApp<HasTokenStore, ProjectId>
{
declare protected _interface: StackAdminInterface;
private readonly _adminProjectCache = createCache(async () => {
return await this._interface.getProject();
});
private readonly _internalApiKeysCache = createCache(async () => {
const res = await this._interface.listInternalApiKeys();
return res;
});
private readonly _adminEmailThemeCache = createCache(async ([id]: [string]) => {
return await this._interface.getEmailTheme(id);
});
private readonly _adminEmailThemesCache = createCache(async () => {
return await this._interface.listEmailThemes();
});
private readonly _adminEmailTemplatesCache = createCache(async () => {
return await this._interface.listInternalEmailTemplates();
});
private readonly _adminTeamPermissionDefinitionsCache = createCache(async () => {
return await this._interface.listTeamPermissionDefinitions();
});
private readonly _adminProjectPermissionDefinitionsCache = createCache(async () => {
return await this._interface.listProjectPermissionDefinitions();
});
private readonly _svixTokenCache = createCache(async () => {
return await this._interface.getSvixToken();
});
private readonly _metricsCache = createCache(async () => {
return await this._interface.getMetrics();
});
private readonly _emailPreviewCache = createCache(async ([themeId, themeTsxSource, templateId, templateTsxSource]: [string | null | false | undefined, string | undefined, string | undefined, string | undefined]) => {
return await this._interface.renderEmailPreview({ themeId, themeTsxSource, templateId, templateTsxSource });
});
private readonly _configOverridesCache = createCache(async () => {
return await this._interface.getConfig();
});
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>) {
super({
interface: new StackAdminInterface({
getBaseUrl: () => getBaseUrl(options.baseUrl),
projectId: options.projectId ?? getDefaultProjectId(),
extraRequestHeaders: options.extraRequestHeaders ?? {},
clientVersion,
..."projectOwnerSession" in options ? {
projectOwnerSession: options.projectOwnerSession,
} : {
publishableClientKey: options.publishableClientKey ?? getDefaultPublishableClientKey(),
secretServerKey: options.secretServerKey ?? getDefaultSecretServerKey(),
superSecretAdminKey: options.superSecretAdminKey ?? getDefaultSuperSecretAdminKey(),
},
}),
baseUrl: options.baseUrl,
extraRequestHeaders: options.extraRequestHeaders,
projectId: options.projectId,
tokenStore: options.tokenStore,
urls: options.urls,
oauthScopesOnSignIn: options.oauthScopesOnSignIn,
redirectMethod: options.redirectMethod,
});
}
_adminConfigFromCrud(data: ConfigCrud['Admin']['Read']): OrganizationRenderedConfig {
return JSON.parse(data.config_string);
}
_adminOwnedProjectFromCrud(data: ProjectsCrud['Admin']['Read'], onRefresh: () => Promise<void>): AdminOwnedProject {
if (this._tokenStoreInit !== null) {
throw new StackAssertionError("Owned apps must always have tokenStore === null — did you not create this project with app._createOwnedApp()?");
}
return {
...this._adminProjectFromCrud(data, onRefresh),
app: this as StackAdminApp<false>,
};
}
_adminProjectFromCrud(data: ProjectsCrud['Admin']['Read'], onRefresh: () => Promise<void>): AdminProject {
if (data.id !== this.projectId) {
throw new StackAssertionError(`The project ID of the provided project JSON (${data.id}) does not match the project ID of the app (${this.projectId})!`);
}
const app = this;
return {
id: data.id,
displayName: data.display_name,
description: data.description,
createdAt: new Date(data.created_at_millis),
isProductionMode: data.is_production_mode,
config: {
signUpEnabled: data.config.sign_up_enabled,
credentialEnabled: data.config.credential_enabled,
magicLinkEnabled: data.config.magic_link_enabled,
passkeyEnabled: data.config.passkey_enabled,
clientTeamCreationEnabled: data.config.client_team_creation_enabled,
clientUserDeletionEnabled: data.config.client_user_deletion_enabled,
allowLocalhost: data.config.allow_localhost,
oauthAccountMergeStrategy: data.config.oauth_account_merge_strategy,
allowUserApiKeys: data.config.allow_user_api_keys,
allowTeamApiKeys: data.config.allow_team_api_keys,
oauthProviders: data.config.oauth_providers.map((p) => ((p.type === 'shared' ? {
id: p.id,
type: 'shared',
} as const : {
id: p.id,
type: 'standard',
clientId: p.client_id ?? throwErr("Client ID is missing"),
clientSecret: p.client_secret ?? throwErr("Client secret is missing"),
facebookConfigId: p.facebook_config_id,
microsoftTenantId: p.microsoft_tenant_id,
} as const))),
emailConfig: data.config.email_config.type === 'shared' ? {
type: 'shared'
} : {
type: 'standard',
host: data.config.email_config.host ?? throwErr("Email host is missing"),
port: data.config.email_config.port ?? throwErr("Email port is missing"),
username: data.config.email_config.username ?? throwErr("Email username is missing"),
password: data.config.email_config.password ?? throwErr("Email password is missing"),
senderName: data.config.email_config.sender_name ?? throwErr("Email sender name is missing"),
senderEmail: data.config.email_config.sender_email ?? throwErr("Email sender email is missing"),
},
emailTheme: data.config.email_theme,
domains: data.config.domains.map((d) => ({
domain: d.domain,
handlerPath: d.handler_path,
})),
createTeamOnSignUp: data.config.create_team_on_sign_up,
teamCreatorDefaultPermissions: data.config.team_creator_default_permissions,
teamMemberDefaultPermissions: data.config.team_member_default_permissions,
userDefaultPermissions: data.config.user_default_permissions,
},
async getConfig() {
return app._adminConfigFromCrud(await app._interface.getConfig());
},
// IF_PLATFORM react-like
useConfig() {
const config = useAsyncCache(app._configOverridesCache, [], "useConfig()");
return useMemo(() => app._adminConfigFromCrud(config), [config]);
},
// END_PLATFORM
async updateConfig(configOverride: EnvironmentConfigOverrideOverride) {
await app._interface.updateConfig({ configOverride });
},
async update(update: AdminProjectUpdateOptions) {
const updateOptions = adminProjectUpdateOptionsToCrud(update);
await app._interface.updateProject(updateOptions);
await onRefresh();
},
async delete() {
await app._interface.deleteProject();
},
async getProductionModeErrors() {
return getProductionModeErrors(data);
},
// IF_PLATFORM react-like
useProductionModeErrors() {
return getProductionModeErrors(data);
},
// END_PLATFORM
};
}
_adminEmailTemplateFromCrud(data: EmailTemplateCrud['Admin']['Read']): AdminEmailTemplate {
return {
type: data.type,
subject: data.subject,
content: data.content,
isDefault: data.is_default,
};
}
override async getProject(): Promise<AdminProject> {
return this._adminProjectFromCrud(
Result.orThrow(await this._adminProjectCache.getOrWait([], "write-only")),
() => this._refreshProject()
);
}
// IF_PLATFORM react-like
override useProject(): AdminProject {
const crud = useAsyncCache(this._adminProjectCache, [], "useProjectAdmin()");
return useMemo(() => this._adminProjectFromCrud(
crud,
() => this._refreshProject()
), [crud]);
}
// END_PLATFORM
protected _createInternalApiKeyBaseFromCrud(data: InternalApiKeyBaseCrudRead): InternalApiKeyBase {
const app = this;
return {
id: data.id,
description: data.description,
expiresAt: new Date(data.expires_at_millis),
manuallyRevokedAt: data.manually_revoked_at_millis ? new Date(data.manually_revoked_at_millis) : null,
createdAt: new Date(data.created_at_millis),
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.revokeInternalApiKeyById(data.id);
await app._refreshInternalApiKeys();
return res;
}
};
}
protected _createInternalApiKeyFromCrud(data: InternalApiKeysCrud["Admin"]["Read"]): InternalApiKey {
return {
...this._createInternalApiKeyBaseFromCrud(data),
publishableClientKey: data.publishable_client_key ? { lastFour: data.publishable_client_key.last_four } : null,
secretServerKey: data.secret_server_key ? { lastFour: data.secret_server_key.last_four } : null,
superSecretAdminKey: data.super_secret_admin_key ? { lastFour: data.super_secret_admin_key.last_four } : null,
};
}
protected _createInternalApiKeyFirstViewFromCrud(data: InternalApiKeyCreateCrudResponse): InternalApiKeyFirstView {
return {
...this._createInternalApiKeyBaseFromCrud(data),
publishableClientKey: data.publishable_client_key,
secretServerKey: data.secret_server_key,
superSecretAdminKey: data.super_secret_admin_key,
};
}
async listInternalApiKeys(): Promise<InternalApiKey[]> {
const crud = Result.orThrow(await this._internalApiKeysCache.getOrWait([], "write-only"));
return crud.map((j) => this._createInternalApiKeyFromCrud(j));
}
// IF_PLATFORM react-like
useInternalApiKeys(): InternalApiKey[] {
const crud = useAsyncCache(this._internalApiKeysCache, [], "useInternalApiKeys()");
return useMemo(() => {
return crud.map((j) => this._createInternalApiKeyFromCrud(j));
}, [crud]);
}
// END_PLATFORM
async createInternalApiKey(options: InternalApiKeyCreateOptions): Promise<InternalApiKeyFirstView> {
const crud = await this._interface.createInternalApiKey(internalApiKeyCreateOptionsToCrud(options));
await this._refreshInternalApiKeys();
return this._createInternalApiKeyFirstViewFromCrud(crud);
}
// IF_PLATFORM react-like
useEmailThemes(): { id: string, displayName: string }[] {
const crud = useAsyncCache(this._adminEmailThemesCache, [], "useEmailThemes()");
return useMemo(() => {
return crud.map((theme) => ({
id: theme.id,
displayName: theme.display_name,
}));
}, [crud]);
}
useEmailTemplates(): { id: string, displayName: string, themeId?: string, tsxSource: string }[] {
const crud = useAsyncCache(this._adminEmailTemplatesCache, [], "useEmailTemplates()");
return useMemo(() => {
return crud.map((template) => ({
id: template.id,
displayName: template.display_name,
themeId: template.theme_id,
tsxSource: template.tsx_source,
}));
}, [crud]);
}
// END_PLATFORM
async listEmailThemes(): Promise<{ id: string, displayName: string }[]> {
const crud = Result.orThrow(await this._adminEmailThemesCache.getOrWait([], "write-only"));
return crud.map((theme) => ({
id: theme.id,
displayName: theme.display_name,
}));
}
async listEmailTemplates(): Promise<{ id: string, displayName: string, themeId?: string, tsxSource: string }[]> {
const crud = Result.orThrow(await this._adminEmailTemplatesCache.getOrWait([], "write-only"));
return crud.map((template) => ({
id: template.id,
displayName: template.display_name,
themeId: template.theme_id,
tsxSource: template.tsx_source,
}));
}
async createTeamPermissionDefinition(data: AdminTeamPermissionDefinitionCreateOptions): Promise<AdminTeamPermission>{
const crud = await this._interface.createTeamPermissionDefinition(adminTeamPermissionDefinitionCreateOptionsToCrud(data));
await this._adminTeamPermissionDefinitionsCache.refresh([]);
return this._serverTeamPermissionDefinitionFromCrud(crud);
}
async updateTeamPermissionDefinition(permissionId: string, data: AdminTeamPermissionDefinitionUpdateOptions) {
await this._interface.updateTeamPermissionDefinition(permissionId, adminTeamPermissionDefinitionUpdateOptionsToCrud(data));
await this._adminTeamPermissionDefinitionsCache.refresh([]);
}
async deleteTeamPermissionDefinition(permissionId: string): Promise<void> {
await this._interface.deleteTeamPermissionDefinition(permissionId);
await this._adminTeamPermissionDefinitionsCache.refresh([]);
}
async listTeamPermissionDefinitions(): Promise<AdminTeamPermissionDefinition[]> {
const crud = Result.orThrow(await this._adminTeamPermissionDefinitionsCache.getOrWait([], "write-only"));
return crud.map((p) => this._serverTeamPermissionDefinitionFromCrud(p));
}
// IF_PLATFORM react-like
useTeamPermissionDefinitions(): AdminTeamPermissionDefinition[] {
const crud = useAsyncCache(this._adminTeamPermissionDefinitionsCache, [], "usePermissions()");
return useMemo(() => {
return crud.map((p) => this._serverTeamPermissionDefinitionFromCrud(p));
}, [crud]);
}
// END_PLATFORM
async createProjectPermissionDefinition(data: AdminProjectPermissionDefinitionCreateOptions): Promise<AdminProjectPermission> {
const crud = await this._interface.createProjectPermissionDefinition(adminProjectPermissionDefinitionCreateOptionsToCrud(data));
await this._adminProjectPermissionDefinitionsCache.refresh([]);
return this._serverProjectPermissionDefinitionFromCrud(crud);
}
async updateProjectPermissionDefinition(permissionId: string, data: AdminProjectPermissionDefinitionUpdateOptions) {
await this._interface.updateProjectPermissionDefinition(permissionId, adminProjectPermissionDefinitionUpdateOptionsToCrud(data));
await this._adminProjectPermissionDefinitionsCache.refresh([]);
}
async deleteProjectPermissionDefinition(permissionId: string): Promise<void> {
await this._interface.deleteProjectPermissionDefinition(permissionId);
await this._adminProjectPermissionDefinitionsCache.refresh([]);
}
async listProjectPermissionDefinitions(): Promise<AdminProjectPermissionDefinition[]> {
const crud = Result.orThrow(await this._adminProjectPermissionDefinitionsCache.getOrWait([], "write-only"));
return crud.map((p) => this._serverProjectPermissionDefinitionFromCrud(p));
}
// IF_PLATFORM react-like
useProjectPermissionDefinitions(): AdminProjectPermissionDefinition[] {
const crud = useAsyncCache(this._adminProjectPermissionDefinitionsCache, [], "useProjectPermissions()");
return useMemo(() => {
return crud.map((p) => this._serverProjectPermissionDefinitionFromCrud(p));
}, [crud]);
}
// END_PLATFORM
// IF_PLATFORM react-like
useSvixToken(): string {
const crud = useAsyncCache(this._svixTokenCache, [], "useSvixToken()");
return crud.token;
}
// END_PLATFORM
protected override async _refreshProject() {
await Promise.all([
super._refreshProject(),
this._adminProjectCache.refresh([]),
]);
}
protected async _refreshInternalApiKeys() {
await this._internalApiKeysCache.refresh([]);
}
get [stackAppInternalsSymbol]() {
return {
...super[stackAppInternalsSymbol],
// IF_PLATFORM react-like
useMetrics: (): any => {
return useAsyncCache(this._metricsCache, [], "useMetrics()");
}
// END_PLATFORM
};
}
async sendTestEmail(options: {
recipientEmail: string,
emailConfig: EmailConfig,
}): Promise<Result<undefined, { errorMessage: string }>> {
const response = await this._interface.sendTestEmail({
recipient_email: options.recipientEmail,
email_config: {
...(pick(options.emailConfig, ['host', 'port', 'username', 'password'])),
sender_email: options.emailConfig.senderEmail,
sender_name: options.emailConfig.senderName,
},
});
if (response.success) {
return Result.ok(undefined);
} else {
return Result.error({ errorMessage: response.error_message ?? throwErr("Email test error not specified") });
}
}
async listSentEmails(): Promise<AdminSentEmail[]> {
const response = await this._interface.listSentEmails();
return response.items.map((email) => ({
id: email.id,
to: email.to ?? [],
subject: email.subject,
recipient: email.to?.[0] ?? "",
sentAt: new Date(email.sent_at_millis),
error: email.error,
}));
}
async sendEmail(options: {
userIds: string[],
subject: string,
content: string,
notificationCategoryName: string,
}): Promise<void> {
await this._interface.sendEmail({
user_ids: options.userIds,
subject: options.subject,
html: options.content,
notification_category_name: options.notificationCategoryName,
});
}
async sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void> {
await this._interface.sendSignInInvitationEmail(email, callbackUrl);
}
async createEmailTemplate(displayName: string): Promise<{ id: string }> {
const result = await this._interface.createEmailTemplate(displayName);
await this._adminEmailTemplatesCache.refresh([]);
return result;
}
async sendChatMessage(
threadId: string,
contextType: "email-theme" | "email-template",
messages: Array<{ role: string, content: any }>,
abortSignal?: AbortSignal,
): Promise<{ content: ChatContent }> {
return await this._interface.sendChatMessage(threadId, contextType, messages, abortSignal);
}
async saveChatMessage(threadId: string, message: any): Promise<void> {
await this._interface.saveChatMessage(threadId, message);
}
async listChatMessages(threadId: string): Promise<{ messages: Array<any> }> {
return await this._interface.listChatMessages(threadId);
}
async createEmailTheme(displayName: string): Promise<{ id: string }> {
const result = await this._interface.createEmailTheme(displayName);
await this._adminEmailThemesCache.refresh([]);
return result;
}
async getEmailPreview(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string }): Promise<string> {
return (await this._interface.renderEmailPreview(options)).html;
}
// IF_PLATFORM react-like
useEmailPreview(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string }): string {
const crud = useAsyncCache(this._emailPreviewCache, [options.themeId, options.themeTsxSource, options.templateId, options.templateTsxSource] as const, "useEmailPreview()");
return crud.html;
}
// END_PLATFORM
// IF_PLATFORM react-like
useEmailTheme(id: string): { displayName: string, tsxSource: string } {
const crud = useAsyncCache(this._adminEmailThemeCache, [id] as const, "useEmailTheme()");
return {
displayName: crud.display_name,
tsxSource: crud.tsx_source,
};
}
// END_PLATFORM
async updateEmailTheme(id: string, tsxSource: string): Promise<void> {
await this._interface.updateEmailTheme(id, tsxSource);
}
async updateEmailTemplate(id: string, tsxSource: string, themeId: string | null | false): Promise<{ renderedHtml: string }> {
const result = await this._interface.updateEmailTemplate(id, tsxSource, themeId);
await this._adminEmailTemplatesCache.refresh([]);
return { renderedHtml: result.rendered_html };
}
}