|
| 1 | +/** |
| 2 | + * Copyright (C) 2026 Percona LLC |
| 3 | + * |
| 4 | + * This program is free software: you can redistribute it and/or modify |
| 5 | + * it under the terms of the GNU Affero General Public License as published by |
| 6 | + * the Free Software Foundation, either version 3 of the License, or |
| 7 | + * (at your option) any later version. |
| 8 | + * |
| 9 | + * This program is distributed in the hope that it will be useful, |
| 10 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | + * GNU Affero General Public License for more details. |
| 13 | + * |
| 14 | + * You should have received a copy of the GNU Affero General Public License |
| 15 | + * along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 16 | + */ |
| 17 | + |
| 18 | +import { createContext, useContext, useMemo } from 'react'; |
| 19 | +import type { User } from './types/api'; |
| 20 | + |
| 21 | +/** |
| 22 | + * Session state owned by the shell's ``AuthProvider``. |
| 23 | + * |
| 24 | + * The context lives here, at the root of the frontend dependency graph, so the |
| 25 | + * framework and every app package can read it — they all depend on ``@sep/api`` |
| 26 | + * and none of them may depend on ``@sep/shell``. The provider itself, and all |
| 27 | + * token/session bookkeeping, stays in the shell. |
| 28 | + * |
| 29 | + * One context carries both the session and the capability derived from it, so a |
| 30 | + * silent-refresh token rotation re-renders every capability consumer too. That |
| 31 | + * is a handful of controls every few minutes; splitting the capability into its |
| 32 | + * own context is the fix if it ever costs more than it saves. |
| 33 | + */ |
| 34 | +export interface AuthSession { |
| 35 | + user: User | null; |
| 36 | + accessToken: string | null; |
| 37 | + isAuthenticated: boolean; |
| 38 | + /** |
| 39 | + * Administrator identity. Read this only for genuinely admin-only surfaces |
| 40 | + * (the shell's Settings / Admin Apps pages and their query suppression). Per-app |
| 41 | + * write controls gate on {@link AuthState.canMutate} instead. |
| 42 | + */ |
| 43 | + isAdmin: boolean; |
| 44 | + /** true during initial session bootstrap & during login */ |
| 45 | + loading: boolean; |
| 46 | + /** true after the initial session check finishes (success or failure) */ |
| 47 | + ready: boolean; |
| 48 | + login: (username: string, password: string) => Promise<void>; |
| 49 | + logout: () => Promise<void>; |
| 50 | +} |
| 51 | + |
| 52 | +/** {@link AuthSession} plus the capabilities derived from it. */ |
| 53 | +export interface AuthState extends AuthSession { |
| 54 | + /** |
| 55 | + * Whether this session may mutate: the gate every per-app create / execute / |
| 56 | + * stop / retry / delete control reads. |
| 57 | + * |
| 58 | + * Semantically distinct from {@link AuthSession.isAdmin} even though it is |
| 59 | + * exactly that today. The server already resolves a minimum role per route |
| 60 | + * rather than one administrator flag, and ``User`` carries that role, so |
| 61 | + * widening the UI to match is an edit to {@link deriveCanMutate} — |
| 62 | + * per-control minimum roles — and to no call site. |
| 63 | + */ |
| 64 | + canMutate: boolean; |
| 65 | +} |
| 66 | + |
| 67 | +/** |
| 68 | + * Single derivation of "may this session mutate?" from session state. |
| 69 | + * |
| 70 | + * Deliberately the administrator flag and nothing finer: most unsafe routes |
| 71 | + * require ``admin``, so keying on a lesser role here would put back the |
| 72 | + * controls that answer 403. Widening this to a per-control minimum role is the |
| 73 | + * follow-up that {@link AuthState.canMutate} describes. |
| 74 | + */ |
| 75 | +export function deriveCanMutate(session: AuthSession): boolean { |
| 76 | + return session.isAdmin; |
| 77 | +} |
| 78 | + |
| 79 | +/** |
| 80 | + * Resolved state for a consumer rendered outside an ``AuthProvider``: signed |
| 81 | + * out, non-admin, and therefore unable to mutate. Tests and Storybook renders |
| 82 | + * mount framework/app components without the shell's provider, so a missing |
| 83 | + * provider must degrade to the least-privileged state rather than throw. |
| 84 | + */ |
| 85 | +export const UNAUTHENTICATED_SESSION: AuthSession = Object.freeze({ |
| 86 | + user: null, |
| 87 | + accessToken: null, |
| 88 | + isAuthenticated: false, |
| 89 | + isAdmin: false, |
| 90 | + loading: false, |
| 91 | + ready: false, |
| 92 | + login: async () => {}, |
| 93 | + logout: async () => {}, |
| 94 | +}); |
| 95 | + |
| 96 | +/** |
| 97 | + * Session state for a signed-in administrator: the mirror of |
| 98 | + * {@link UNAUTHENTICATED_SESSION}, and the fixture every "an admin still sees |
| 99 | + * this control" render needs. |
| 100 | + * |
| 101 | + * A test fixture living in shipped code, deliberately. It belongs beside the |
| 102 | + * constant it mirrors, and the alternative — a ``@sep/test-utils`` export — |
| 103 | + * would drag ``@sep/api`` into every package's vitest setup file, where the |
| 104 | + * eagerly-loaded real module defeats ``vi.mock('@sep/api')`` in suites that |
| 105 | + * have nothing to do with auth. |
| 106 | + * |
| 107 | + * Do not hand this to ``AuthContext`` in application code: the shell's |
| 108 | + * ``AuthProvider`` owns the real session, and a hardcoded admin one only |
| 109 | + * unlocks controls the API still refuses. |
| 110 | + */ |
| 111 | +export const ADMIN_SESSION: AuthSession = Object.freeze({ |
| 112 | + ...UNAUTHENTICATED_SESSION, |
| 113 | + isAuthenticated: true, |
| 114 | + isAdmin: true, |
| 115 | + ready: true, |
| 116 | +}); |
| 117 | + |
| 118 | +export const AuthContext = createContext<AuthSession | null>(null); |
| 119 | + |
| 120 | +let warnedMissingProvider = false; |
| 121 | + |
| 122 | +/** |
| 123 | + * Warn once per bundle when the provider is missing. Hiding controls is a |
| 124 | + * quieter failure than the throw this replaced, so a stray consumer mounted |
| 125 | + * outside the provider would otherwise silently look like a non-admin session. |
| 126 | + */ |
| 127 | +function warnMissingProvider(): void { |
| 128 | + if (warnedMissingProvider || !import.meta.env?.DEV) { |
| 129 | + return; |
| 130 | + } |
| 131 | + warnedMissingProvider = true; |
| 132 | + // eslint-disable-next-line no-console -- surface a silently degraded session in dev |
| 133 | + console.warn( |
| 134 | + 'useAuth() was called outside an AuthProvider — falling back to a signed-out, ' + |
| 135 | + 'non-admin session. Mutation controls will be hidden.', |
| 136 | + ); |
| 137 | +} |
| 138 | + |
| 139 | +/** |
| 140 | + * Read the current session and its derived capabilities. |
| 141 | + * |
| 142 | + * Resolves to {@link UNAUTHENTICATED_SESSION} when no provider is mounted. |
| 143 | + */ |
| 144 | +export function useAuth(): AuthState { |
| 145 | + const session = useContext(AuthContext); |
| 146 | + if (!session) { |
| 147 | + warnMissingProvider(); |
| 148 | + } |
| 149 | + const resolved = session ?? UNAUTHENTICATED_SESSION; |
| 150 | + return useMemo(() => ({ ...resolved, canMutate: deriveCanMutate(resolved) }), [resolved]); |
| 151 | +} |
0 commit comments