diff --git a/.changeset/tame-plums-sing.md b/.changeset/tame-plums-sing.md new file mode 100644 index 000000000..ca6c4e937 --- /dev/null +++ b/.changeset/tame-plums-sing.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge": patch +"@truefoundry/trueforge-ui": patch +--- + +Show the logged-in user's account (avatar, email, role) with logout in the shell footer, replacing the bare logout icon. diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 8c1b348c4..3465790fe 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { createAuthAwareFetch } from './authFetch'; import { probeSession, type SessionState } from './authSession'; import { parseAuthErrorReason, shouldShowAuthErrorScreen, stripAuthErrorSearch } from './authStatusSearch'; import { GetStartedScreen } from './GetStartedScreen'; -import { LogoutButton } from './LogoutButton'; +import { UserProfile } from './UserProfile'; /** Shared cookie/OIDC fetch for boot helpers and ``. */ const authAwareFetch = createAuthAwareFetch(); @@ -113,7 +113,7 @@ export function App() { }; }, [session]); - const overrides: SlotOverrides = useMemo(() => ({ ShellActionsActionSlot: LogoutButton }), []); + const overrides: SlotOverrides = useMemo(() => ({ ShellActionsActionSlot: UserProfile }), []); const authErrorReason = shouldShowAuthErrorScreen({ authError, session }); if (authErrorReason != null) { diff --git a/packages/frontend/src/LogoutButton.css b/packages/frontend/src/LogoutButton.css deleted file mode 100644 index 6911ae813..000000000 --- a/packages/frontend/src/LogoutButton.css +++ /dev/null @@ -1,50 +0,0 @@ -/* Confirm dialog footer for the logout flow (rendered inside CenteredModal). */ - -.harness-logout-confirm { - display: flex; - gap: 0.75rem; - padding: 1.25rem; -} - -.harness-logout-cancel, -.harness-logout-submit { - flex: 1 1 0; - display: inline-flex; - min-height: 2.5rem; - align-items: center; - justify-content: center; - border-radius: 0.625rem; - padding: 0 1rem; - font-size: 0.9375rem; - font-weight: 500; - letter-spacing: -0.005em; - cursor: pointer; - transition: opacity 0.15s ease; -} - -.harness-logout-cancel { - border: 1px solid var(--border); - background: transparent; - color: var(--text-primary); -} - -.harness-logout-cancel:hover:not(:disabled) { - background: color-mix(in srgb, var(--text-primary) 8%, transparent); -} - -.harness-logout-submit { - border: 0; - background: var(--failure-bg); - color: var(--failure-text); -} - -.harness-logout-submit:hover:not(:disabled) { - opacity: 0.85; -} - -.harness-logout-cancel:disabled, -.harness-logout-submit:disabled { - opacity: 0.5; - cursor: default; - pointer-events: none; -} diff --git a/packages/frontend/src/LogoutButton.tsx b/packages/frontend/src/LogoutButton.tsx deleted file mode 100644 index 36f089c8c..000000000 --- a/packages/frontend/src/LogoutButton.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { CenteredModal, Icon } from '@truefoundry/trueforge-ui'; -import { useEffect, useState } from 'react'; -import { getCachedIsOidcConnectedSession, isOidcConnectedSession, logout } from './authSession'; -import './LogoutButton.css'; - -/** - * Icon button next to Settings (via `ShellActionsActionSlot` override). - * Shown only when `auth.me()` returns `type: "oidc-connected"`. - * Uses a module cache so remounts of the action slot do not hide the control during refetches. - */ -export function LogoutButton() { - const [visible, setVisible] = useState(() => getCachedIsOidcConnectedSession() === true); - const [confirmOpen, setConfirmOpen] = useState(false); - const [busy, setBusy] = useState(false); - - useEffect(() => { - const state = { cancelled: false }; - void isOidcConnectedSession() - .then(ok => { - if (!state.cancelled) { - setVisible(ok); - } - }) - .catch(() => { - // Failed refetch must not clear a known-good session (e.g. transient network). - if (!state.cancelled && getCachedIsOidcConnectedSession() === undefined) { - setVisible(false); - } - }); - return () => { - state.cancelled = true; - }; - }, []); - - if (!visible) { - return null; - } - - const handleConfirm = () => { - if (busy) { - return; - } - setBusy(true); - void logout() - .then(() => { - // Land on the welcome gate (probe /me → unauthenticated → GetStartedScreen). - window.location.assign('/'); - }) - .catch(() => { - setBusy(false); - }); - }; - - return ( - <> - - - { - if (!busy) { - setConfirmOpen(next); - } - }} - title="Log out" - description="Sure to logout?" - contentSized - > -
- - -
-
- - ); -} diff --git a/packages/frontend/src/UserProfile.css b/packages/frontend/src/UserProfile.css new file mode 100644 index 000000000..3adf5b5de --- /dev/null +++ b/packages/frontend/src/UserProfile.css @@ -0,0 +1,171 @@ +/* Account trigger row in the shell footer, its profile modal, and the logout confirm dialog. */ + +.harness-user-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 0; + border-radius: 9999px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background 0.15s ease; +} + +.harness-user-trigger:hover { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); +} + +.harness-user-trigger:active { + background: color-mix(in srgb, var(--text-primary) 12%, transparent); +} + +.harness-user-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--focus-ring); +} + +.harness-user-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 9999px; + background: color-mix(in srgb, var(--text-primary) 14%, transparent); + color: var(--text-primary); + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + flex-shrink: 0; +} + +.harness-user-avatar-lg { + width: 2.75rem; + height: 2.75rem; + font-size: 1.125rem; +} + +.harness-user-modal { + display: flex; + flex-direction: column; +} + +.harness-user-modal-header { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 1.25rem; + border-bottom: 1px solid var(--border); +} + +.harness-user-modal-identity { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.125rem; +} + +.harness-user-modal-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: -0.005em; +} + +.harness-user-modal-role { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.harness-user-modal-actions { + padding: 0.5rem; +} + +.harness-user-logout-row { + --harness-danger-ink: color-mix(in srgb, var(--color-red-500) 78%, black); + + display: flex; + width: 100%; + align-items: center; + gap: 0.625rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + padding: 0.625rem 0.75rem; + color: var(--harness-danger-ink); + font-size: 0.9375rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: background 0.15s ease; +} + +.harness-user-logout-icon { + display: inline-flex; +} + +.harness-user-logout-row:hover { + background: color-mix(in srgb, var(--failure-bg) 12%, transparent); +} + +.harness-user-logout-row:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--focus-ring); +} + +/* Confirm dialog footer for the logout flow (rendered inside CenteredModal). */ + +.harness-logout-confirm { + display: flex; + gap: 0.75rem; + padding: 1.25rem; +} + +.harness-logout-cancel, +.harness-logout-submit { + flex: 1 1 0; + display: inline-flex; + min-height: 2.5rem; + align-items: center; + justify-content: center; + border-radius: 0.625rem; + padding: 0 1rem; + font-size: 0.9375rem; + font-weight: 500; + letter-spacing: -0.005em; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.harness-logout-cancel { + border: 1px solid var(--border); + background: transparent; + color: var(--text-primary); +} + +.harness-logout-cancel:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); +} + +.harness-logout-submit { + border: 0; + background: var(--failure-bg); + color: var(--failure-text); +} + +.harness-logout-submit:hover:not(:disabled) { + opacity: 0.85; +} + +.harness-logout-cancel:disabled, +.harness-logout-submit:disabled { + opacity: 0.5; + cursor: default; + pointer-events: none; +} diff --git a/packages/frontend/src/UserProfile.tsx b/packages/frontend/src/UserProfile.tsx new file mode 100644 index 000000000..2bac00325 --- /dev/null +++ b/packages/frontend/src/UserProfile.tsx @@ -0,0 +1,150 @@ +import { CenteredModal, Icon } from '@truefoundry/trueforge-ui'; +import { useEffect, useState } from 'react'; +import { + getCachedEmail, + getCachedIsOidcConnectedSession, + getCachedRole, + isOidcConnectedSession, + logout, +} from './authSession'; +import './UserProfile.css'; + +/** First letter of the email local-part, uppercased — placeholder avatar until real photos exist. */ +function initialFor(email: string | undefined): string { + return email?.trim().charAt(0).toUpperCase() || '?'; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +/** + * Profile row next to Settings (via `ShellActionsActionSlot` override). + * Shown only when `auth.me()` returns `type: "oidc-connected"`. + * Uses a module cache so remounts of the action slot do not hide the control during refetches. + */ +export function UserProfile() { + const [visible, setVisible] = useState(() => getCachedIsOidcConnectedSession() === true); + const [email, setEmail] = useState(() => getCachedEmail()); + const [role, setRole] = useState(() => getCachedRole()); + const [profileOpen, setProfileOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const state = { cancelled: false }; + void isOidcConnectedSession() + .then(ok => { + if (!state.cancelled) { + setVisible(ok); + setEmail(getCachedEmail()); + setRole(getCachedRole()); + } + }) + .catch(() => { + // Failed refetch must not clear a known-good session (e.g. transient network). + if (!state.cancelled && getCachedIsOidcConnectedSession() === undefined) { + setVisible(false); + } + }); + return () => { + state.cancelled = true; + }; + }, []); + + if (!visible) { + return null; + } + + const handleConfirm = () => { + if (busy) { + return; + } + setBusy(true); + void logout() + .then(() => { + // Land on the welcome gate (probe /me → unauthenticated → GetStartedScreen). + window.location.assign('/'); + }) + .catch(() => { + setBusy(false); + }); + }; + + return ( + <> + + + +
+
+ +
+ + {email} + + {role ? {capitalize(role)} : null} +
+
+
+ +
+
+
+ + { + if (!busy) { + setConfirmOpen(next); + } + }} + title="Log out" + description="Sure to logout?" + contentSized + > +
+ + +
+
+ + ); +} diff --git a/packages/frontend/src/authSession.ts b/packages/frontend/src/authSession.ts index 06e95799c..1b2286388 100644 --- a/packages/frontend/src/authSession.ts +++ b/packages/frontend/src/authSession.ts @@ -27,14 +27,28 @@ export type SessionState = 'authenticated' | 'unauthenticated'; /** Last successful me() OIDC check — survives remounts of host chrome. */ let cachedIsOidcConnected: boolean | undefined; +/** Email from the last successful me() call — survives remounts of host chrome. */ +let cachedEmail: string | undefined; +/** Role from the last successful me() call — survives remounts of host chrome. */ +let cachedRole: string | undefined; export function getCachedIsOidcConnectedSession(): boolean | undefined { return cachedIsOidcConnected; } +export function getCachedEmail(): string | undefined { + return cachedEmail; +} + +export function getCachedRole(): string | undefined { + return cachedRole; +} + /** Test-only: clear the module cache between cases. */ export function resetOidcSessionCacheForTests(): void { cachedIsOidcConnected = undefined; + cachedEmail = undefined; + cachedRole = undefined; } /** @@ -45,6 +59,8 @@ export async function isOidcConnectedSession(client: TrueForge = authClient): Pr const session = await client.auth.me(); const isConnected = session.type === 'oidc-connected'; cachedIsOidcConnected = isConnected; + cachedEmail = session.email; + cachedRole = session.role; return isConnected; } diff --git a/packages/trueforge-ui/src/icons/IconRegistry.tsx b/packages/trueforge-ui/src/icons/IconRegistry.tsx index f80f061ef..803960c85 100644 --- a/packages/trueforge-ui/src/icons/IconRegistry.tsx +++ b/packages/trueforge-ui/src/icons/IconRegistry.tsx @@ -53,6 +53,7 @@ import { Terminal, Trash2, TriangleAlert, + User, Wrench, X, } from 'lucide-react'; @@ -168,6 +169,7 @@ const defaults: Record = { compress: Minimize2, 'external-link': ExternalLink, github: Github, + user: User, }; for (const [name, icon] of Object.entries(defaults)) {