wcz-layout
Version:
1 lines • 17.4 kB
Source Map (JSON)
{"version":3,"file":"utils-uByKcwM2.mjs","names":["OFFLINE_USER_KEY","OFFLINE_PHOTO_KEY","GRACE_PERIOD","Cached","value","T","savedAt","recall","key","localStorage","raw","getItem","JSON","parse","Date","now","removeItem","remember","undefined","setItem","stringify","isOfflineFailure","error","navigator","onLine","TypeError","withOfflineFallback","fetchFresh","Promise","fresh","clearSessionSnapshot","String","window","addEventListener","event","location","href","serverEnv","getAppSession","useSession","getSession","name","password","SESSION_SECRET","queryOptions","createServerFn","createServerOnlyFn","scopes","definedScopes","QueryClientParam","User","OFFLINE_USER_KEY","withOfflineFallback","getAppSession","getSessionUser","method","handler","Promise","session","data","user","userQueryOptions","queryKey","queryFn","staleTime","Infinity","networkMode","refetchOnReconnect","getUser","queryClient","import","meta","env","SSR","ensureQueryData","getAccessToken","scopeKey","refreshToken","Error","acquireDelegatedToken","SessionExpiredError","accessToken","update","error","clear","Response","json","message","status","AnyFieldApi","redirect","ParsedLocation","manifest","permissions","QueryClientParam","TokenPayload","User","getUser","WISTRON_PRIMARY_COLOR","WISTRON_SECONDARY_COLOR","Platform","isAndroid","test","userAgent","isIOS","isWindows","isMacOS","navigator","RootRouteHeadOptions","rootRouteHead","options","meta","charSet","name","content","title","links","rel","sizes","href","type","requirePermission","permissionKey","location","context","user","queryClient","encodeURIComponent","reloadDocument","hasPermission","Error","FormOmittedProps","getFieldStatus","field","state","isTouched","hasError","errors","length","helperText","message","toKebabCase","str","replaceAll","toLowerCase","buildUser","payload","split","email","preferred_username","department","toUpperCase","employeeId","companyName","groups","key","some","group","includes"],"sources":["../src/lib/auth/offline.ts","../src/lib/auth/session.ts","../src/lib/auth/user.ts","../src/lib/utils.ts"],"sourcesContent":["/**\n * Offline fallback for identity. The refresh token never leaves the sealed\n * httpOnly cookie — what is cached here is only the display snapshot (user\n * claims and avatar) so the UI and offline route permissions can still work\n * while the server is unreachable. Server requests remain independently\n * authorized. At-rest protection is the device's OS login and disk encryption.\n */\n\nexport const OFFLINE_USER_KEY = \"wcz-auth-user\";\nexport const OFFLINE_PHOTO_KEY = \"wcz-auth-photo\";\n\n/** One shift — matches Intune's default offline grace period. */\nconst GRACE_PERIOD = 12 * 60 * 60 * 1000;\n\ntype Cached<T> = { value: T; savedAt: number };\n\nconst recall = <T>(key: string): T | null => {\n if (typeof localStorage === \"undefined\") return null;\n try {\n const raw = localStorage.getItem(key);\n if (!raw) return null;\n const { value, savedAt } = JSON.parse(raw) as Cached<T>;\n if (Date.now() - savedAt > GRACE_PERIOD) {\n localStorage.removeItem(key);\n return null;\n }\n return value;\n } catch {\n localStorage.removeItem(key);\n return null;\n }\n};\n\nconst remember = (key: string, value: unknown): void => {\n if (typeof localStorage === \"undefined\") return;\n try {\n if (value === null || value === undefined) localStorage.removeItem(key);\n else localStorage.setItem(key, JSON.stringify({ value, savedAt: Date.now() }));\n } catch {\n // Storage full or blocked — the offline fallback is best-effort.\n }\n};\n\nconst isOfflineFailure = (error: unknown): boolean =>\n (typeof navigator !== \"undefined\" && !navigator.onLine) || error instanceof TypeError;\n\n/**\n * Answers from the server when reachable, remembering the result; when the call\n * has a transport failure, replays the last result instead, for up to\n * {@link GRACE_PERIOD} since it was last confirmed.\n *\n * Cached values are untrusted client state. Server authorization must never\n * depend on them.\n */\nexport const withOfflineFallback = async <T>(\n key: string,\n fetchFresh: () => Promise<T>,\n): Promise<T | null> => {\n try {\n const fresh = await fetchFresh();\n remember(key, fresh);\n return fresh;\n } catch (error) {\n if (!isOfflineFailure(error)) throw error;\n return recall<T>(key);\n }\n};\n\n/** Drops the cached identity. Call on logout, before leaving the page. */\nexport const clearSessionSnapshot = (): void => {\n if (typeof localStorage === \"undefined\") return;\n localStorage.removeItem(OFFLINE_USER_KEY);\n localStorage.removeItem(OFFLINE_PHOTO_KEY);\n localStorage.setItem(\"wcz-auth-logout\", String(Date.now()));\n};\n\nif (typeof window !== \"undefined\")\n window.addEventListener(\"storage\", (event) => {\n if (event.key === \"wcz-auth-logout\") location.href = \"/auth/logout\";\n });\n","import { serverEnv } from \"~/env\";\r\n\r\nexport const getAppSession = async () => {\r\n // Dynamic import: `@tanstack/react-start/server` is the server entry and drags\r\n // in the SSR renderer (`react-dom/server`). Keep it out of the client graph —\r\n // this helper is only ever called server-side, and all callers already await it.\r\n // Aliased off the `use*` name so it isn't mistaken for a React hook (it's a\r\n // server session helper, not a hook).\r\n const { useSession: getSession } = await import(\"@tanstack/react-start/server\");\r\n\r\n return getSession({\r\n name: \"session\",\r\n password: serverEnv.SESSION_SECRET,\r\n });\r\n};\r\n","import { queryOptions } from \"@tanstack/react-query\";\r\nimport { createServerFn, createServerOnlyFn } from \"@tanstack/react-start\";\r\nimport { scopes as definedScopes } from \"virtual:wcz-layout\";\r\nimport type { QueryClientParam } from \"~/models/QueryClientParam\";\r\nimport type { User } from \"~/models/User\";\r\nimport { OFFLINE_USER_KEY, withOfflineFallback } from \"./offline\";\r\nimport { getAppSession } from \"./session\";\r\n\r\n/**\r\n * Reads the signed-in user from the session cookie, or null. As a server function\r\n * it runs in-process when called on the server (SSR, middleware) and as an RPC\r\n * when called from the client — so it doubles as the client `queryFn`.\r\n */\r\nexport const getSessionUser = createServerFn({ method: \"GET\" }).handler(\r\n async (): Promise<User | null> => {\r\n const session = await getAppSession();\r\n return session.data.user ?? null;\r\n },\r\n);\r\n\r\n/**\r\n * Client-side query for the signed-in user. Shared between `getUser` (route\r\n * `beforeLoad`) and the always-mounted observer in `LayoutProvider` — the\r\n * observer is what seeds the offline snapshot on the very first page load\r\n * (`beforeLoad` does not re-run after SSR hydration) and what makes\r\n * `refetchOnReconnect` fire once connectivity returns.\r\n */\r\nexport const userQueryOptions = queryOptions({\r\n queryKey: [\"auth\", \"user\"],\r\n queryFn: () => withOfflineFallback(OFFLINE_USER_KEY, () => getSessionUser()),\r\n staleTime: Infinity,\r\n // Default \"online\" pauses the query (never calling queryFn) once the browser\r\n // reports no connection — which is exactly when the fallback is needed.\r\n networkMode: \"offlineFirst\",\r\n // Back online: re-confirm the session and re-stamp the snapshot's grace period.\r\n refetchOnReconnect: \"always\",\r\n});\r\n\r\nexport const getUser = ({ queryClient }: QueryClientParam): Promise<User | null> => {\r\n if (import.meta.env.SSR) return getSessionUser();\r\n\r\n return queryClient.ensureQueryData(userQueryOptions);\r\n};\r\n\r\n/**\r\n * Server-only token acquisition: a delegated access token for the given API\r\n * scope, minted from the user's session refresh token. Entra rotates the refresh\r\n * token on each use, so the rotated token is persisted back to the session. Use\r\n * inside server functions and middleware — it is stripped from the client bundle\r\n * and throws if called there.\r\n */\r\nexport const getAccessToken = createServerOnlyFn(\r\n async (scopeKey: keyof typeof definedScopes): Promise<string> => {\r\n const session = await getAppSession();\r\n if (!session.data.refreshToken) throw new Error(\"No active session. User not signed in.\");\r\n\r\n // Dynamic import so `entra` (and its `@azure/msal-node` dependency) stays out\r\n // of the client graph — this module also exports the isomorphic `getUser`.\r\n const { acquireDelegatedToken, SessionExpiredError } = await import(\"./entra\");\r\n try {\r\n const { accessToken, refreshToken } = await acquireDelegatedToken({\r\n refreshToken: session.data.refreshToken,\r\n scopes: definedScopes[scopeKey],\r\n });\r\n\r\n if (refreshToken !== session.data.refreshToken) await session.update({ refreshToken });\r\n return accessToken;\r\n } catch (error) {\r\n // Revoked/expired refresh token: clear the dead session and answer 401 so\r\n // the client can redirect to /auth/login, instead of surfacing a 500.\r\n if (error instanceof SessionExpiredError) {\r\n await session.clear();\r\n throw Response.json(\r\n { message: \"Unauthorized: Session expired, sign in again\" },\r\n { status: 401 },\r\n );\r\n }\r\n throw error;\r\n }\r\n },\r\n);\r\n","import type { AnyFieldApi } from \"@tanstack/react-form\";\r\nimport { redirect } from \"@tanstack/react-router\";\r\nimport type { ParsedLocation } from \"@tanstack/react-router\";\r\nimport { manifest, permissions } from \"virtual:wcz-layout\";\r\nimport type { QueryClientParam } from \"~/models/QueryClientParam\";\r\nimport type { TokenPayload } from \"~/models/TokenPayload\";\r\nimport type { User } from \"~/models/User\";\r\nimport { getUser } from \"./auth/user\";\r\n\r\nexport const WISTRON_PRIMARY_COLOR = \"#00506E\";\r\nexport const WISTRON_SECONDARY_COLOR = \"#64DC00\";\r\n\r\nexport class Platform {\r\n static get isAndroid() {\r\n return /android/i.test(this.userAgent);\r\n }\r\n static get isIOS() {\r\n return /iPad|iPhone|iPod/.test(this.userAgent);\r\n }\r\n static get isWindows() {\r\n return /windows/i.test(this.userAgent);\r\n }\r\n static get isMacOS() {\r\n return /Macintosh|MacIntel|MacPPC|Mac68K/.test(this.userAgent);\r\n }\r\n\r\n private static get userAgent() {\r\n return typeof navigator === \"undefined\" ? \"\" : navigator.userAgent;\r\n }\r\n}\r\n\r\ninterface RootRouteHeadOptions {\r\n manifest?: string;\r\n}\r\n\r\nexport const rootRouteHead = (options?: RootRouteHeadOptions) => ({\r\n meta: [\r\n { charSet: \"utf-8\" },\r\n { name: \"viewport\", content: \"width=device-width, initial-scale=1\" },\r\n { title: manifest.name },\r\n { name: \"og:type\", content: \"website\" },\r\n { name: \"og:title\", content: manifest.name },\r\n { name: \"og:image\", content: \"/favicon-32x32.png\" },\r\n ],\r\n links: [\r\n { rel: \"apple-touch-icon\", sizes: \"180x180\", href: \"/apple-touch-icon.png\" },\r\n { rel: \"icon\", type: \"image/png\", sizes: \"32x32\", href: \"/favicon-32x32.png\" },\r\n { rel: \"icon\", type: \"image/png\", sizes: \"16x16\", href: \"/favicon-16x16.png\" },\r\n { rel: \"manifest\", href: options?.manifest || \"/manifest.json\" },\r\n { rel: \"icon\", href: \"/favicon.ico\" },\r\n ],\r\n});\r\n\r\nexport const requirePermission = (permissionKey: keyof typeof permissions) => {\r\n return async ({ location, context }: { location: ParsedLocation; context: QueryClientParam }) => {\r\n const user = await getUser({ queryClient: context.queryClient });\r\n\r\n if (!user)\r\n throw redirect({\r\n href: `/auth/login?returnTo=${encodeURIComponent(location.href)}`,\r\n reloadDocument: true,\r\n });\r\n\r\n if (!hasPermission(user, permissionKey))\r\n throw new Error(\"You do not have permission to access this page.\");\r\n\r\n return { user };\r\n };\r\n};\r\n\r\n/* Internal Utils */\r\nexport type FormOmittedProps =\r\n | \"name\"\r\n | \"value\"\r\n | \"onChange\"\r\n | \"onBlur\"\r\n | \"error\"\r\n | \"helperText\"\r\n | \"renderInput\"\r\n | \"type\"\r\n | \"aria-label\";\r\n\r\nexport const getFieldStatus = (field: AnyFieldApi) => {\r\n const { meta } = field.state;\r\n\r\n const isTouched = meta.isTouched;\r\n const hasError = !!meta.errors.length;\r\n const helperText = meta.errors[0]?.message;\r\n\r\n return { isTouched, hasError, helperText };\r\n};\r\n\r\nexport const toKebabCase = (str: string): string => {\r\n return str\r\n .replaceAll(/([a-z])([A-Z])/g, \"$1-$2\")\r\n .replaceAll(/[\\s_]+/g, \"-\")\r\n .replaceAll(/[^a-zA-Z0-9-]/g, \"\")\r\n .toLowerCase()\r\n .replaceAll(/-+/g, \"-\")\r\n .replaceAll(/(^-|-$)/g, \"\");\r\n};\r\n\r\nexport const buildUser = (payload: TokenPayload): User => ({\r\n name: payload.name?.split(\"/\")[0],\r\n email: payload.preferred_username?.toLowerCase(),\r\n department: payload.department?.toUpperCase() || \"\",\r\n employeeId: payload.employeeId?.toUpperCase() || \"\",\r\n companyName: payload.companyName || \"\",\r\n groups: payload.groups ?? [],\r\n});\r\n\r\nexport const hasPermission = (user: User | null, key: keyof typeof permissions): boolean =>\r\n user ? permissions[key].some((group) => user.groups.includes(group)) : false;\r\n"],"mappings":"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"}