feat(auth): implement GuestOnly component to restrict access to auth routes
ZhenYi committed
Modified lib/config/src/livekit.rs lib/config/src/livekit.rs +3 −1 View lib/config/src/livekit.rs
@@ -45,7 +45,9 @@
4545
4646fn scheme_ok(value: &str, allowed: &[&str]) -> bool {
4747 match value.split_once("://") {
48 Some((scheme, rest)) => !rest.is_empty() && allowed.contains(&scheme.to_ascii_lowercase().as_str()),
48 Some((scheme, rest)) => {
49 !rest.is_empty() && allowed.contains(&scheme.to_ascii_lowercase().as_str())
50 }
4951 None => false,
5052 }
5153}
Modified web/src/app/routes/deferred-routes.tsx web/src/app/routes/deferred-routes.tsx +5 −4 View web/src/app/routes/deferred-routes.tsx
@@ -1,6 +1,7 @@
11import { lazy, Suspense, type ReactNode } from 'react'
22import { Loader2 } from 'lucide-react'
33import { useT } from '@/shared/i18n'
4import { GuestOnly } from '@/features/auth'
45
56const WorkspaceRouteContent = lazy(() => import('./workspace-route').then(route => ({ default: route.WorkspaceRouteContent })))
67const AgreementPage = lazy(() => import('@/pages/agreement').then(page => ({ default: page.AgreementPage })))
@@ -31,15 +32,15 @@
3132}
3233
3334export function LoginRoute() {
34 return <DeferredRoute><LoginPage /></DeferredRoute>
35 return <DeferredRoute><GuestOnly><LoginPage /></GuestOnly></DeferredRoute>
3536}
3637
3738export function SignUpRoute() {
38 return <DeferredRoute><SignUpPage /></DeferredRoute>
39 return <DeferredRoute><GuestOnly><SignUpPage /></GuestOnly></DeferredRoute>
3940}
4041
4142export function ForgotPasswordRoute() {
42 return <DeferredRoute><ForgotPasswordPage /></DeferredRoute>
43 return <DeferredRoute><GuestOnly><ForgotPasswordPage /></GuestOnly></DeferredRoute>
4344}
4445
4546export function ResetPasswordRoute() {
@@ -51,7 +52,7 @@
5152}
5253
5354export function TwoFactorRoute() {
54 return <DeferredRoute><TwoFactorPage /></DeferredRoute>
55 return <DeferredRoute><GuestOnly><TwoFactorPage /></GuestOnly></DeferredRoute>
5556}
5657
5758export function AgreementRoute() {
Modified web/src/app/styles/workspace.css web/src/app/styles/workspace.css +19 −1 View web/src/app/styles/workspace.css
@@ -98,8 +98,26 @@
9898}
9999@media (min-width: 1201px) {
100100 body:has(.chat-side-panel) .global-search-trigger { right: calc(var(--global-chat-panel-width, 380px) + 16px); }
101}
102/*
103 * A docked detail pane pulls the search box off the window corner and into the
104 * chat column, so the header has to reserve the field's gutter. Below the width
105 * that can host both, that reservation starved the very header it floats over —
106 * at a 1243px window the column is 539px, the gutter took 302px of it and the
107 * title collapsed to zero width, stacking the topic one character per line in a
108 * 300px-tall bar. Under that width the trigger renders as its icon only (same
109 * affordance as the bar's other actions, still opens the palette on click) and
110 * the header reserves just the icon.
111 */
112@media (min-width: 1201px) and (max-width: 1599px) {
113 body:has(.chat-side-panel) .global-search-trigger { width: 36px; justify-content: center; padding-inline: 0; }
114 body:has(.chat-side-panel) .global-search-trigger > :is(span, kbd) { display: none; }
115 body:has(.chat-side-panel) .workspace-main .chat-header { padding-right: 64px; }
116}
117@media (min-width: 1201px) {
101118 /* A custom-width detail pane becomes the reading focus. Hide the global
102 * command until that pane closes, and release its former header gutter. */
119 * command until that pane closes, and release its former header gutter.
120 * Declared after the width ladder above so it wins at every width. */
103121 body:has(.chat-panel-resized) .global-search-trigger { display: none; }
104122 body:has(.chat-panel-resized) .workspace-main .chat-header { padding-right: 24px; }
105123}
Added web/src/features/auth/model/session.ts web/src/features/auth/model/session.ts +22 −0 View web/src/features/auth/model/session.ts
@@ -0,0 +1,22 @@
1import Apis, { type AppResponseData_MeResp } from '@/shared/api'
2import { ApiFailure, meKey, unwrapApi } from '@/shared/lib'
3import { invalidateCache } from '@/shared/storage'
4
5/** Identity behind `GET /me` — the same payload the workspace shell reads. */
6export type SessionUser = NonNullable<AppResponseData_MeResp['data']>
7
8/**
9 * Session probe behind the auth routes' guest guard. Shares `meKey()` with the
10 * workspace shell, so a signed-in visit redirects off an already cached
11 * identity and the fresh copy warms the shell's own frame.
12 */
13export function fetchSessionUser(): Promise<SessionUser> {
14 return unwrapApi<SessionUser>(Apis.account.me()).catch(err => {
15 // A refused session must not stay cached: the guard redirects on a cached
16 // identity, so a dead one would bounce the shell back to login forever.
17 if (err instanceof ApiFailure && (err.code === 401 || err.code === 403)) {
18 invalidateCache(meKey())
19 }
20 throw err
21 })
22}
Added web/src/features/auth/ui/guest-only.tsx web/src/features/auth/ui/guest-only.tsx +39 −0 View web/src/features/auth/ui/guest-only.tsx
@@ -0,0 +1,39 @@
1import type { ReactNode } from 'react'
2import { Navigate } from 'react-router-dom'
3import { Loader2 } from 'lucide-react'
4import { useT } from '@/shared/i18n'
5import { ApiFailure, meKey, useAsync } from '@/shared/lib'
6import { fetchSessionUser } from '../model/session'
7
8/**
9 * Gate for the auth entry screens (login / sign up / forgot password): a
10 * visitor who already holds a session belongs in the workspace, so the sign-in
11 * form must never be reachable — not by clicking a stale link, not by typing
12 * the URL, not by hitting Back into it.
13 *
14 * The probe shares `meKey()` with the shell, so an in-app visit redirects off
15 * the identity already on screen; a cold visit waits for `GET /me` and only
16 * paints the form once the session is known to be gone (a 401 also drops the
17 * cached identity, see `fetchSessionUser`).
18 */
19export function GuestOnly({ children }: { children: ReactNode }) {
20 const { t } = useT()
21 const me = useAsync(fetchSessionUser, [], { cacheKey: meKey() })
22
23 // A *refused* session outranks the cached identity — otherwise a signed-out
24 // browser would be redirected into a shell that can only answer 401. Any
25 // other failure (offline, 5xx) leaves the cached identity trusted, because
26 // the alternative is showing a sign-in form to someone already signed in.
27 const refused =
28 me.error instanceof ApiFailure && (me.error.code === 401 || me.error.code === 403)
29 if (!refused && me.data) return <Navigate to="/app" replace />
30 if (me.loading) {
31 return (
32 <main className="grid min-h-dvh place-items-center bg-background text-foreground" role="status">
33 <Loader2 size={22} className="animate-spin text-primary" aria-hidden />
34 <span className="sr-only">{t('common.action.loading')}</span>
35 </main>
36 )
37 }
38 return <>{children}</>
39}
Modified web/src/features/auth/index.ts web/src/features/auth/index.ts +2 −0 View web/src/features/auth/index.ts
@@ -5,7 +5,9 @@
55export { KernelMark, type KernelMarkProps } from '@/shared/brand'
66export { IconBadge, type IconBadgeProps } from './ui/icon-badge'
77export { Chip, Pill, type ChipProps, type PillProps } from './ui/chip'
8export { GuestOnly } from './ui/guest-only'
89export { getPowAnswer } from './model/pow'
10export { fetchSessionUser, type SessionUser } from './model/session'
911export {
1012 ApiError,
1113 toUserError,
Modified web/src/features/chat/ui/chat-polish.css web/src/features/chat/ui/chat-polish.css +1 −1 View web/src/features/chat/ui/chat-polish.css
@@ -23,7 +23,7 @@
2323 background: var(--shell-field);
2424}
2525.chat-heading { min-width: 0; flex: 1; }
26.chat-topic { margin-top: 2px; color: var(--shell-ink-muted); font-size: 12px; line-height: 1.5; }
26.chat-topic { margin-top: 2px; overflow: hidden; color: var(--shell-ink-muted); font-size: 12px; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; }
2727.chat-header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; }
2828.chat-header-actions > button, .chat-search-action, .chat-panel-close {
2929 display: inline-grid;
Modified web/src/pages/workspace/model/shell-api.ts web/src/pages/workspace/model/shell-api.ts +5 −1 View web/src/pages/workspace/model/shell-api.ts
@@ -7,6 +7,7 @@
77import Apis from '@/shared/api'
88import { settingsKey, unwrapApi, type ApiEnvelope } from '@/shared/lib'
99import { writeCache } from '@/shared/storage'
10import { fetchSessionUser } from '@/features/auth'
1011import type {
1112 BoardItem,
1213 DmItem,
@@ -18,7 +19,10 @@
1819 UserPreferences,
1920} from './workspace'
2021
21export const fetchMe = () => unwrapApi<Me>(Apis.account.me())
22// Identity goes through the auth slice's probe so a refused session invalidates
23// the cached `me` on both sides: the shell redirects to login, and the auth
24// routes' guest guard does not bounce the visitor straight back into `/app`.
25export const fetchMe = (): Promise<Me> => fetchSessionUser()
2226
2327/**
2428 * The account's preference row, cached under the same key the settings modal