From 6692194250928e3ee7d4f7f0a832a4bc15f3db7c Mon Sep 17 00:00:00 2001 From: CactusBlue Date: Tue, 18 Feb 2025 20:54:24 -0800 Subject: [PATCH] Improve auth method selection (#442) * move brand icons * add icons * modify the auth page * change how dialog works * preview * improve the auth methods page * better predicate types * convert to table * fix more stuff * refactor * add default case * edit table * add config * add brand colors * icon refresh * fix a bug with shared tooltips * Refactor auth methods page and preview with UI improvements * Simplify provider update confirmation with async/await * Update packages/stack-ui/src/components/brand-icons.tsx Co-authored-by: Konsti Wohlwend * update deps * more fixes --------- Co-authored-by: Zai Shi Co-authored-by: Konsti Wohlwend --- .../new-project/page-client.tsx | 29 +- .../[projectId]/auth-methods/page-client.tsx | 278 ++++++++++++++---- .../[projectId]/auth-methods/providers.tsx | 57 ++-- .../src/components/data-table/user-table.tsx | 2 +- apps/dashboard/src/components/settings.tsx | 3 +- .../stack-ui/src/components/brand-icons.tsx | 217 ++++++++++++++ packages/stack-ui/src/index.ts | 1 + .../template/src/components/oauth-button.tsx | 164 +---------- 8 files changed, 501 insertions(+), 250 deletions(-) create mode 100644 packages/stack-ui/src/components/brand-icons.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx index 7365c1a76..a90f10d08 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx @@ -124,23 +124,20 @@ export default function PageClient () {
- { - ( -
- -
-
- {/* a transparent cover that prevents the card from being clicked, even when pointer-events is overridden */} -
- -
-
-
+
+ +
+
+ {/* a transparent cover that prevents the card from being clicked, even when pointer-events is overridden */} +
+ +
- )} +
+
); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 38e41d315..f97c1803e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -1,13 +1,15 @@ "use client"; import { SettingCard, SettingSwitch } from "@/components/settings"; +import { AdminOAuthProviderConfig, AuthPage, OAuthProviderConfig } from "@stackframe/stack"; import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth"; -import { ActionDialog, Typography } from "@stackframe/stack-ui"; +import { ActionDialog, Badge, BrandIcons, BrowserFrame, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, SimpleTooltip, Typography } from "@stackframe/stack-ui"; +import { AsteriskSquare, CirclePlus, Key, Link2, MoreHorizontal } from "lucide-react"; import { useState } from "react"; import { CardSubtitle } from "../../../../../../../../../packages/stack-ui/dist/components/ui/card"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; -import { ProviderSettingSwitch } from "./providers"; +import { ProviderSettingDialog, ProviderSettingSwitch, TurnOffProviderDialog } from "./providers"; function ConfirmSignUpEnabledDialog(props: { open?: boolean, @@ -73,57 +75,33 @@ function ConfirmSignUpDisabledDialog(props: { ); } -export default function PageClient() { +function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpenChange?: (open: boolean) => void }) { const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); const oauthProviders = project.config.oauthProviders; - const [confirmSignUpEnabled, setConfirmSignUpEnabled] = useState(false); - const [confirmSignUpDisabled, setConfirmSignUpDisabled] = useState(false); + const [providerSearch, setProviderSearch] = useState(""); + const filteredProviders = allProviders + .filter((id) => id.toLowerCase().includes(providerSearch.toLowerCase())) + .map((id) => [id, oauthProviders.find((provider) => provider.id === id)] as const) + .filter(([, provider]) => { + return !provider?.enabled; + }); - return ( - - - - Email-based - - { - await project.update({ - config: { - credentialEnabled: checked, - }, - }); - }} - /> - { - await project.update({ - config: { - magicLinkEnabled: checked, - }, - }); - }} - /> - { - await project.update({ - config: { - passkeyEnabled: checked, - }, - }); - }} - /> - - SSO (OAuth) - - {allProviders.map((id) => { - const provider = oauthProviders.find((provider) => provider.id === id); + return + setProviderSearch(e.target.value)} + /> +
+ {filteredProviders + .map(([id, provider]) => { return ; })} - + + { filteredProviders.length === 0 && No providers found. } +
+ +
; +} + +function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const oauthProviders = project.config.oauthProviders; + const [turnOffProviderDialogOpen, setTurnOffProviderDialogOpen] = useState(false); + const [providerSettingDialogOpen, setProviderSettingDialogOpen] = useState(false); + + + const updateProvider = async (provider: AdminOAuthProviderConfig & OAuthProviderConfig) => { + const alreadyExist = oauthProviders.some((p) => p.id === config.id); + const newOAuthProviders = oauthProviders.map((p) => p.id === config.id ? provider : p); + if (!alreadyExist) { + newOAuthProviders.push(provider); + } + await project.update({ + config: { oauthProviders: newOAuthProviders }, + }); + }; + + return ( + + setTurnOffProviderDialogOpen(false)} + providerId={config.id} + onConfirm={async () => { + await updateProvider({ + ...config, + id: config.id, + enabled: false + }); + }} + /> + setProviderSettingDialogOpen(false)} + updateProvider={updateProvider} + /> + + + + + + { setProviderSettingDialogOpen(true); }}> + Configure + + { setTurnOffProviderDialogOpen(true); }} + > + Disable Provider + + + + ); +} + +const SHARED_TOOLTIP = "Shared keys are automatically created by Stack, but show Stack's logo on the OAuth sign-in page.\n\nYou should replace these before you go into production."; + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const oauthProviders = project.config.oauthProviders; + const [confirmSignUpEnabled, setConfirmSignUpEnabled] = useState(false); + const [confirmSignUpDisabled, setConfirmSignUpDisabled] = useState(false); + const [disabledProvidersDialogOpen, setDisabledProvidersDialogOpen] = useState(false); + + const enabledProviders = allProviders + .map((id) => [id, oauthProviders.find((provider) => provider.id === id)] as const) + .filter(([, provider]) => provider?.enabled); + + return ( + +
+ + +
+ } + checked={project.config.credentialEnabled} + onCheckedChange={async (checked) => { + await project.update({ + config: { + credentialEnabled: checked, + }, + }); + }} + /> + + + Magic link (Email OTP) + + } + checked={project.config.magicLinkEnabled} + onCheckedChange={async (checked) => { + await project.update({ + config: { + magicLinkEnabled: checked, + }, + }); + }} + /> + + + Passkey + + } + checked={project.config.passkeyEnabled} + onCheckedChange={async (checked) => { + await project.update({ + config: { + passkeyEnabled: checked, + }, + }); + }} + /> + + SSO Providers + + + { enabledProviders.map(([, provider]) => provider) + .filter((provider): provider is AdminOAuthProviderConfig => !!provider).map(provider => { + return
+
+
+ +
+ {BrandIcons.toTitle(provider.id)} + {provider.type === 'shared' && + Shared keys + } +
+ + +
; + }) } + + + { + setDisabledProvidersDialogOpen(x); + }} + /> +
+ +
+
+ +
+
+ {/* a transparent cover that prevents the card from being clicked, even when pointer-events is overridden */} +
+ provider) + .filter((provider): provider is AdminOAuthProviderConfig => !!provider), + }, + }} + /> +
+
+
+
+
+
+ void, - onConfirm: () => void, + onConfirm: () => Promise, providerId: string, }) { return ( @@ -172,7 +171,7 @@ export function TurnOffProviderDialog(props: { okButton={{ label: `Disable ${toTitle(props.providerId)}`, onClick: async () => { - props.onConfirm(); + await props.onConfirm(); }, }} cancelButton @@ -202,35 +201,33 @@ export function ProviderSettingSwitch(props: Props) { return ( <> - - {toTitle(props.id)} - {isShared && enabled && - - Shared keys - - } - +
{ + if (enabled) { + setTurnOffProviderDialogOpen(true); + } else { + setProviderSettingDialogOpen(true); } - checked={enabled} - onCheckedChange={async (checked) => { - if (!checked) { - setTurnOffProviderDialogOpen(true); - return; - } else { - setProviderSettingDialogOpen(true); - } - }} - actions={ setProviderSettingDialogOpen(true)} />} - onlyShowActionsWhenChecked - /> + }} + > + + {toTitle(props.id)} + {isShared && enabled && + + Shared keys + + } +
setTurnOffProviderDialogOpen(false)} providerId={props.id} - onConfirm={() => runAsynchronously(updateProvider(false))} + onConfirm={async () => { + await updateProvider(false); + }} /> setProviderSettingDialogOpen(false)} /> diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 58b0fbd59..6be2bccb9 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -173,7 +173,7 @@ export const getCommonUserColumns = () => [ }, ] satisfies ColumnDef[]; -const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ ...getCommonUserColumns(), { accessorKey: "authTypes", diff --git a/apps/dashboard/src/components/settings.tsx b/apps/dashboard/src/components/settings.tsx index 55c88942a..85235bf8d 100644 --- a/apps/dashboard/src/components/settings.tsx +++ b/apps/dashboard/src/components/settings.tsx @@ -14,9 +14,10 @@ export function SettingCard(props: { actions?: React.ReactNode, children?: React.ReactNode, accordion?: string, + className?: string, }) { return ( - + {(props.title || props.description) && ( {props.title && {props.title}} diff --git a/packages/stack-ui/src/components/brand-icons.tsx b/packages/stack-ui/src/components/brand-icons.tsx new file mode 100644 index 000000000..46a1ba686 --- /dev/null +++ b/packages/stack-ui/src/components/brand-icons.tsx @@ -0,0 +1,217 @@ +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; + +export function Google({ iconSize } : { iconSize: number} ) { + return ( + + + + + + + + ); +} + +export function Facebook({ iconSize } : { iconSize: number} ) { + return ( + + + + ); +} + +export function GitHub({ iconSize } : { iconSize: number} ) { + return ( + + + + ); +} + +export function Microsoft({ iconSize } : { iconSize: number} ) { + return ( + + {"MS-SymbolLockup"} + + + + + + ); +} + +export function Spotify({ iconSize } : { iconSize: number} ) { + return ( + + + + ); +} + +export function Discord({ iconSize } : { iconSize: number} ) { + return ( + + + + ); +} + +export function Gitlab({ iconSize } : { iconSize: number} ) { + return ( + + + + + + + + + + + + ); +} + +export function Bitbucket({ iconSize }: { iconSize: number }) { + return ( + + + + + + + + + + + + ); +} + +export function LinkedIn({ iconSize } : { iconSize: number} ) { + return ( + + + + + + + + ); +} + +export function Apple({ iconSize } : { iconSize: number} ) { + return ( + + + + + ); +} + +export function X({ iconSize } : { iconSize: number} ) { + return ( + + + + ); +} + +export function Mapping({ + provider, + iconSize, +}: { + provider: string, + iconSize: number, +}) { + switch (provider) { + case "google": { + return ; + } + case "github": { + return ; + } + case "facebook": { + return ; + } + case "microsoft": { + return ; + } + case "spotify": { + return ; + } + case "discord": { + return ; + } + case "gitlab": { + return ; + } + case "bitbucket": { + return ; + } + case "linkedin": { + return ; + } + case "apple": { + return ; + } + case "x": { + return ; + } + default: { + throw new StackAssertionError(`Icon not found for provider: ${provider}`);; + } + } +} + +export function toTitle(id: string) { + return { + github: "GitHub", + google: "Google", + facebook: "Facebook", + microsoft: "Microsoft", + spotify: "Spotify", + discord: "Discord", + gitlab: "GitLab", + apple: "Apple", + bitbucket: "Bitbucket", + linkedin: "LinkedIn", + x: "X", + }[id] || throwErr(`Unknown provider: ${id}`); +} + +export const BRAND_COLORS: Record = { + github: '#24292e', + google: '#ffffff', + facebook: '#0866FF', + microsoft: '#2F2F2F', + spotify: '#1DD65F', + discord: '#5661F5', + linkedin: '#0A66C2', + x: '#000000', +}; diff --git a/packages/stack-ui/src/index.ts b/packages/stack-ui/src/index.ts index 107b22c41..356f20588 100644 --- a/packages/stack-ui/src/index.ts +++ b/packages/stack-ui/src/index.ts @@ -1,5 +1,6 @@ export * from "./components/action-dialog"; +export * as BrandIcons from "./components/brand-icons"; export * from "./components/browser-frame"; export * from "./components/copy-button"; export * from "./components/copy-field"; diff --git a/packages/template/src/components/oauth-button.tsx b/packages/template/src/components/oauth-button.tsx index c416d9100..204d26e01 100644 --- a/packages/template/src/components/oauth-button.tsx +++ b/packages/template/src/components/oauth-button.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Button } from '@stackframe/stack-ui'; +import { BrandIcons, Button } from '@stackframe/stack-ui'; import Color from 'color'; import { useId } from 'react'; import { useStackApp } from '..'; @@ -8,146 +8,6 @@ import { useTranslation } from '../lib/translations'; const iconSize = 22; -function GoogleIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - - - - - ); -} - -function FacebookIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - ); -} - -function GitHubIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - ); -} - -function MicrosoftIcon({ iconSize } : { iconSize: number} ) { - return ( - - {"MS-SymbolLockup"} - - - - - - ); -} - -function SpotifyIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - ); -} -function DiscordIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - ); -} -function GitlabIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - - - - - - - - - ); -} - -function BitbucketIcon({ iconSize }: { iconSize: number }) { - return ( - - - - - - - - - - - - ); -} - -function LinkedInIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - - - - - ); -} - -function AppleIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - - ); -} - -function XIcon({ iconSize } : { iconSize: number} ) { - return ( - - - - ); -} - const changeColor = (c: Color, value: number) => { if (c.isLight()) { value = -value; @@ -180,7 +40,7 @@ export function OAuthButton({ textColor: '#000', name: 'Google', border: '1px solid #ddd', - icon: , + icon: , }; break; } @@ -190,7 +50,7 @@ export function OAuthButton({ textColor: '#fff', border: '1px solid #333', name: 'GitHub', - icon: , + icon: , }; break; } @@ -199,7 +59,7 @@ export function OAuthButton({ backgroundColor: '#1877F2', textColor: '#fff', name: 'Facebook', - icon: , + icon: , }; break; } @@ -208,7 +68,7 @@ export function OAuthButton({ backgroundColor: '#2f2f2f', textColor: '#fff', name: 'Microsoft', - icon: , + icon: , }; break; } @@ -217,7 +77,7 @@ export function OAuthButton({ backgroundColor: '#1DB954', textColor: '#fff', name: 'Spotify', - icon: , + icon: , }; break; } @@ -226,7 +86,7 @@ export function OAuthButton({ backgroundColor: '#5865F2', textColor: '#fff', name: 'Discord', - icon: , + icon: , }; break; } @@ -236,7 +96,7 @@ export function OAuthButton({ textColor: "#fff", border: "1px solid #333", name: "Gitlab", - icon: , + icon: , }; break; } @@ -246,7 +106,7 @@ export function OAuthButton({ textColor: "#fff", border: "1px solid #333", name: "Apple", - icon: , + icon: , }; break; } @@ -256,7 +116,7 @@ export function OAuthButton({ textColor: "#000", border: "1px solid #ddd", name: "Bitbucket", - icon: , + icon: , }; break; } @@ -265,7 +125,7 @@ export function OAuthButton({ backgroundColor: "#0073b1", textColor: "#fff", name: "LinkedIn", - icon: , + icon: , }; break; } @@ -274,7 +134,7 @@ export function OAuthButton({ backgroundColor: "#000", textColor: "#fff", name: "X", - icon: , + icon: , }; break; }