From 4184595a0ce82658b68899635c7e4e7e92a8132d Mon Sep 17 00:00:00 2001 From: MOHAK GUPTA Date: Sat, 22 Aug 2026 23:53:48 +0530 Subject: [PATCH 1/3] feat(frontend): show account profile row with logout Surfaces the logged-in user's email/role instead of a bare logout icon, so a shared/self-hosted deployment shows whose session is active. --- packages/frontend/src/App.tsx | 4 +- packages/frontend/src/LogoutButton.css | 50 ----- packages/frontend/src/LogoutButton.tsx | 97 ---------- packages/frontend/src/UserProfile.css | 180 ++++++++++++++++++ packages/frontend/src/UserProfile.tsx | 151 +++++++++++++++ packages/frontend/src/authSession.ts | 16 ++ .../trueforge-ui/src/icons/IconRegistry.tsx | 2 + 7 files changed, 351 insertions(+), 149 deletions(-) delete mode 100644 packages/frontend/src/LogoutButton.css delete mode 100644 packages/frontend/src/LogoutButton.tsx create mode 100644 packages/frontend/src/UserProfile.css create mode 100644 packages/frontend/src/UserProfile.tsx 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..06da19e4a --- /dev/null +++ b/packages/frontend/src/UserProfile.css @@ -0,0 +1,180 @@ +/* Account trigger row in the shell footer, its profile modal, and the logout confirm dialog. */ + +.harness-user-trigger { + display: inline-flex; + align-items: center; + gap: 0.5rem; + max-width: 12rem; + min-height: 2.25rem; + padding: 0 0.5rem 0 0.25rem; + 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-email { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8125rem; + font-weight: 500; +} + +.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..01c701910 --- /dev/null +++ b/packages/frontend/src/UserProfile.tsx @@ -0,0 +1,151 @@ +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)) { From b403bb60c9ddd51a37ff790468887366a493b6db Mon Sep 17 00:00:00 2001 From: MOHAK GUPTA Date: Sat, 22 Aug 2026 23:55:32 +0530 Subject: [PATCH 2/3] chore: add changeset for account profile UI --- .changeset/tame-plums-sing.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tame-plums-sing.md diff --git a/.changeset/tame-plums-sing.md b/.changeset/tame-plums-sing.md new file mode 100644 index 000000000..f5bd50e49 --- /dev/null +++ b/.changeset/tame-plums-sing.md @@ -0,0 +1,6 @@ +--- +"frontend": 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. From 46c28499ea91e1c8f171e4ed1a38a3de45c12b63 Mon Sep 17 00:00:00 2001 From: MOHAK GUPTA Date: Sun, 23 Aug 2026 00:16:07 +0530 Subject: [PATCH 3/3] fix: correct changeset package names for account profile UI frontend is private (bundled into @truefoundry/trueforge); the changeset must bump the published package, not the private one. --- .changeset/tame-plums-sing.md | 2 +- packages/frontend/src/UserProfile.css | 17 ++++------------- packages/frontend/src/UserProfile.tsx | 1 - 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/.changeset/tame-plums-sing.md b/.changeset/tame-plums-sing.md index f5bd50e49..ca6c4e937 100644 --- a/.changeset/tame-plums-sing.md +++ b/.changeset/tame-plums-sing.md @@ -1,5 +1,5 @@ --- -"frontend": patch +"@truefoundry/trueforge": patch "@truefoundry/trueforge-ui": patch --- diff --git a/packages/frontend/src/UserProfile.css b/packages/frontend/src/UserProfile.css index 06da19e4a..3adf5b5de 100644 --- a/packages/frontend/src/UserProfile.css +++ b/packages/frontend/src/UserProfile.css @@ -3,10 +3,10 @@ .harness-user-trigger { display: inline-flex; align-items: center; - gap: 0.5rem; - max-width: 12rem; - min-height: 2.25rem; - padding: 0 0.5rem 0 0.25rem; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; border: 0; border-radius: 9999px; background: transparent; @@ -49,15 +49,6 @@ font-size: 1.125rem; } -.harness-user-email { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.8125rem; - font-weight: 500; -} - .harness-user-modal { display: flex; flex-direction: column; diff --git a/packages/frontend/src/UserProfile.tsx b/packages/frontend/src/UserProfile.tsx index 01c701910..2bac00325 100644 --- a/packages/frontend/src/UserProfile.tsx +++ b/packages/frontend/src/UserProfile.tsx @@ -85,7 +85,6 @@ export function UserProfile() { - {email ? {email} : null}