create-top-secret-starter 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +117 -0
- package/package.json +33 -0
- package/templates/template-router/.env.example +1 -0
- package/templates/template-router/.oxfmtrc.json +13 -0
- package/templates/template-router/.oxlintrc.json +8 -0
- package/templates/template-router/_gitignore +39 -0
- package/templates/template-router/components.json +25 -0
- package/templates/template-router/index.html +24 -0
- package/templates/template-router/package.json +58 -0
- package/templates/template-router/public/favicon.svg +1 -0
- package/templates/template-router/src/api/auth/auth.test.ts +46 -0
- package/templates/template-router/src/api/auth/guards.ts +18 -0
- package/templates/template-router/src/api/auth/index.ts +46 -0
- package/templates/template-router/src/api/auth/refresh.test.ts +89 -0
- package/templates/template-router/src/api/auth/router-bridge.test.ts +74 -0
- package/templates/template-router/src/api/auth/router-bridge.ts +26 -0
- package/templates/template-router/src/api/auth/schema.ts +20 -0
- package/templates/template-router/src/api/auth/session-store.test.ts +101 -0
- package/templates/template-router/src/api/auth/session-store.ts +56 -0
- package/templates/template-router/src/api/index.ts +51 -0
- package/templates/template-router/src/components/status-page.tsx +68 -0
- package/templates/template-router/src/components/ui/button.tsx +58 -0
- package/templates/template-router/src/components/ui/dialog.tsx +136 -0
- package/templates/template-router/src/components/ui/empty.tsx +94 -0
- package/templates/template-router/src/components/ui/field.tsx +222 -0
- package/templates/template-router/src/components/ui/input.tsx +20 -0
- package/templates/template-router/src/components/ui/label.tsx +18 -0
- package/templates/template-router/src/components/ui/select.tsx +188 -0
- package/templates/template-router/src/components/ui/separator.tsx +21 -0
- package/templates/template-router/src/components/ui/skeleton.tsx +13 -0
- package/templates/template-router/src/components/ui/sonner.tsx +43 -0
- package/templates/template-router/src/components/ui/tooltip.tsx +52 -0
- package/templates/template-router/src/env.ts +20 -0
- package/templates/template-router/src/index.css +134 -0
- package/templates/template-router/src/lib/query-client.test.ts +82 -0
- package/templates/template-router/src/lib/query-client.ts +30 -0
- package/templates/template-router/src/lib/single-flight.test.ts +48 -0
- package/templates/template-router/src/lib/single-flight.ts +9 -0
- package/templates/template-router/src/lib/utils.ts +1 -0
- package/templates/template-router/src/main.tsx +26 -0
- package/templates/template-router/src/mocks/mock-server.ts +149 -0
- package/templates/template-router/src/providers/index.tsx +27 -0
- package/templates/template-router/src/providers/theme.test.tsx +68 -0
- package/templates/template-router/src/providers/theme.tsx +76 -0
- package/templates/template-router/src/routeTree.gen.ts +102 -0
- package/templates/template-router/src/router.tsx +29 -0
- package/templates/template-router/src/routes/-error.tsx +34 -0
- package/templates/template-router/src/routes/-not-found.tsx +27 -0
- package/templates/template-router/src/routes/__root.tsx +15 -0
- package/templates/template-router/src/routes/_authenticated/index.tsx +9 -0
- package/templates/template-router/src/routes/_authenticated.tsx +38 -0
- package/templates/template-router/src/routes/sign-in.tsx +100 -0
- package/templates/template-router/src/test/setup.ts +5 -0
- package/templates/template-router/tsconfig.app.json +29 -0
- package/templates/template-router/tsconfig.json +7 -0
- package/templates/template-router/tsconfig.node.json +23 -0
- package/templates/template-router/vite.config.ts +33 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
@import 'tw-animate-css';
|
|
3
|
+
@import 'shadcn/tailwind.css';
|
|
4
|
+
@import '@fontsource-variable/inter';
|
|
5
|
+
|
|
6
|
+
@custom-variant dark (&:is(.dark *));
|
|
7
|
+
|
|
8
|
+
@theme inline {
|
|
9
|
+
--font-heading: var(--font-sans);
|
|
10
|
+
--font-sans: 'Inter Variable', sans-serif;
|
|
11
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
12
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
13
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
14
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
15
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
16
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
17
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
18
|
+
--color-sidebar: var(--sidebar);
|
|
19
|
+
--color-chart-5: var(--chart-5);
|
|
20
|
+
--color-chart-4: var(--chart-4);
|
|
21
|
+
--color-chart-3: var(--chart-3);
|
|
22
|
+
--color-chart-2: var(--chart-2);
|
|
23
|
+
--color-chart-1: var(--chart-1);
|
|
24
|
+
--color-ring: var(--ring);
|
|
25
|
+
--color-input: var(--input);
|
|
26
|
+
--color-border: var(--border);
|
|
27
|
+
--color-destructive: var(--destructive);
|
|
28
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
29
|
+
--color-accent: var(--accent);
|
|
30
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
31
|
+
--color-muted: var(--muted);
|
|
32
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
33
|
+
--color-secondary: var(--secondary);
|
|
34
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
35
|
+
--color-primary: var(--primary);
|
|
36
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
37
|
+
--color-popover: var(--popover);
|
|
38
|
+
--color-card-foreground: var(--card-foreground);
|
|
39
|
+
--color-card: var(--card);
|
|
40
|
+
--color-foreground: var(--foreground);
|
|
41
|
+
--color-background: var(--background);
|
|
42
|
+
--radius-sm: calc(var(--radius) * 0.6);
|
|
43
|
+
--radius-md: calc(var(--radius) * 0.8);
|
|
44
|
+
--radius-lg: var(--radius);
|
|
45
|
+
--radius-xl: calc(var(--radius) * 1.4);
|
|
46
|
+
--radius-2xl: calc(var(--radius) * 1.8);
|
|
47
|
+
--radius-3xl: calc(var(--radius) * 2.2);
|
|
48
|
+
--radius-4xl: calc(var(--radius) * 2.6);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
:root {
|
|
52
|
+
--background: oklch(1 0 0);
|
|
53
|
+
--foreground: oklch(0.145 0 0);
|
|
54
|
+
--card: oklch(1 0 0);
|
|
55
|
+
--card-foreground: oklch(0.145 0 0);
|
|
56
|
+
--popover: oklch(1 0 0);
|
|
57
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
58
|
+
--primary: oklch(0.205 0 0);
|
|
59
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
60
|
+
--secondary: oklch(0.97 0 0);
|
|
61
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
62
|
+
--muted: oklch(0.97 0 0);
|
|
63
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
64
|
+
--accent: oklch(0.97 0 0);
|
|
65
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
66
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
67
|
+
--border: oklch(0.922 0 0);
|
|
68
|
+
--input: oklch(0.922 0 0);
|
|
69
|
+
--ring: oklch(0.708 0 0);
|
|
70
|
+
--chart-1: oklch(0.87 0 0);
|
|
71
|
+
--chart-2: oklch(0.556 0 0);
|
|
72
|
+
--chart-3: oklch(0.439 0 0);
|
|
73
|
+
--chart-4: oklch(0.371 0 0);
|
|
74
|
+
--chart-5: oklch(0.269 0 0);
|
|
75
|
+
--radius: 0.625rem;
|
|
76
|
+
--sidebar: oklch(0.985 0 0);
|
|
77
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
78
|
+
--sidebar-primary: oklch(0.205 0 0);
|
|
79
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
80
|
+
--sidebar-accent: oklch(0.97 0 0);
|
|
81
|
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
82
|
+
--sidebar-border: oklch(0.922 0 0);
|
|
83
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.dark {
|
|
87
|
+
--background: oklch(0.145 0 0);
|
|
88
|
+
--foreground: oklch(0.985 0 0);
|
|
89
|
+
--card: oklch(0.205 0 0);
|
|
90
|
+
--card-foreground: oklch(0.985 0 0);
|
|
91
|
+
--popover: oklch(0.205 0 0);
|
|
92
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
93
|
+
--primary: oklch(0.922 0 0);
|
|
94
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
95
|
+
--secondary: oklch(0.269 0 0);
|
|
96
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
97
|
+
--muted: oklch(0.269 0 0);
|
|
98
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
99
|
+
--accent: oklch(0.269 0 0);
|
|
100
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
101
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
102
|
+
--border: oklch(1 0 0 / 10%);
|
|
103
|
+
--input: oklch(1 0 0 / 15%);
|
|
104
|
+
--ring: oklch(0.556 0 0);
|
|
105
|
+
--chart-1: oklch(0.87 0 0);
|
|
106
|
+
--chart-2: oklch(0.556 0 0);
|
|
107
|
+
--chart-3: oklch(0.439 0 0);
|
|
108
|
+
--chart-4: oklch(0.371 0 0);
|
|
109
|
+
--chart-5: oklch(0.269 0 0);
|
|
110
|
+
--sidebar: oklch(0.205 0 0);
|
|
111
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
112
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
113
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
114
|
+
--sidebar-accent: oklch(0.269 0 0);
|
|
115
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
116
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
117
|
+
--sidebar-ring: oklch(0.556 0 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@layer base {
|
|
121
|
+
* {
|
|
122
|
+
@apply border-border outline-ring/50;
|
|
123
|
+
}
|
|
124
|
+
body {
|
|
125
|
+
@apply bg-background text-foreground;
|
|
126
|
+
}
|
|
127
|
+
button:not(:disabled),
|
|
128
|
+
[role='button']:not(:disabled) {
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
}
|
|
131
|
+
html {
|
|
132
|
+
@apply font-sans;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { HTTPError } from 'ky'
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { queryClient } from '@/lib/query-client'
|
|
5
|
+
|
|
6
|
+
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
|
7
|
+
const { toast } = await import('sonner')
|
|
8
|
+
|
|
9
|
+
const httpError = (status: number) =>
|
|
10
|
+
new HTTPError(
|
|
11
|
+
new Response(null, { status }),
|
|
12
|
+
new Request('http://localhost/api/x'),
|
|
13
|
+
// ky only reads .retry off the options object here
|
|
14
|
+
{ retry: {} } as never
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.mocked(toast.error).mockClear()
|
|
19
|
+
queryClient.clear()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// notifyError imports sonner dynamically, so the toast lands a tick after the
|
|
23
|
+
// query/mutation promise settles
|
|
24
|
+
const flushToasts = () => vi.dynamicImportSettled()
|
|
25
|
+
|
|
26
|
+
it('stays silent when a query fails with no data on screen', async () => {
|
|
27
|
+
await queryClient
|
|
28
|
+
.fetchQuery({
|
|
29
|
+
queryKey: ['a'],
|
|
30
|
+
queryFn: () => Promise.reject(httpError(500)),
|
|
31
|
+
retry: false
|
|
32
|
+
})
|
|
33
|
+
.catch(() => {})
|
|
34
|
+
await flushToasts()
|
|
35
|
+
|
|
36
|
+
// the route's error component already tells the user; a toast would double up
|
|
37
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('toasts when a background refetch fails over data already shown', async () => {
|
|
41
|
+
await queryClient.fetchQuery({ queryKey: ['b'], queryFn: () => Promise.resolve('data') })
|
|
42
|
+
|
|
43
|
+
await queryClient
|
|
44
|
+
.fetchQuery({
|
|
45
|
+
queryKey: ['b'],
|
|
46
|
+
queryFn: () => Promise.reject(httpError(500)),
|
|
47
|
+
staleTime: 0,
|
|
48
|
+
retry: false
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {})
|
|
51
|
+
await flushToasts()
|
|
52
|
+
|
|
53
|
+
expect(toast.error).toHaveBeenCalledTimes(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('always toasts a failed mutation', async () => {
|
|
57
|
+
await queryClient
|
|
58
|
+
.getMutationCache()
|
|
59
|
+
.build(queryClient, { mutationFn: () => Promise.reject(new Error('nope')) })
|
|
60
|
+
.execute(undefined)
|
|
61
|
+
.catch(() => {})
|
|
62
|
+
await flushToasts()
|
|
63
|
+
|
|
64
|
+
expect(toast.error).toHaveBeenCalledTimes(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('does not retry a 4xx query', async () => {
|
|
68
|
+
const queryFn = vi.fn(() => Promise.reject(httpError(404)))
|
|
69
|
+
|
|
70
|
+
await queryClient.fetchQuery({ queryKey: ['c'], queryFn }).catch(() => {})
|
|
71
|
+
|
|
72
|
+
expect(queryFn).toHaveBeenCalledTimes(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('retries a 5xx query up to the configured limit', async () => {
|
|
76
|
+
const queryFn = vi.fn(() => Promise.reject(httpError(503)))
|
|
77
|
+
|
|
78
|
+
await queryClient.fetchQuery({ queryKey: ['d'], queryFn, retryDelay: 0 }).catch(() => {})
|
|
79
|
+
|
|
80
|
+
// initial attempt + 2 retries
|
|
81
|
+
expect(queryFn).toHaveBeenCalledTimes(3)
|
|
82
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { HTTPError } from 'ky'
|
|
3
|
+
|
|
4
|
+
const isClientError = (error: unknown) =>
|
|
5
|
+
error instanceof HTTPError && error.response.status >= 400 && error.response.status < 500
|
|
6
|
+
|
|
7
|
+
// Dynamic import keeps sonner out of the entry chunk (~8 kB gzip). Safe to fire
|
|
8
|
+
// before <Toaster> mounts: sonner replays active toasts to a new subscriber.
|
|
9
|
+
const notifyError = (error: unknown) => {
|
|
10
|
+
const message = error instanceof Error ? error.message : 'Something went wrong'
|
|
11
|
+
void import('sonner').then(({ toast }) => toast.error(message))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const queryClient = new QueryClient({
|
|
15
|
+
queryCache: new QueryCache({
|
|
16
|
+
onError: (error, query) => {
|
|
17
|
+
if (query.state.data !== undefined) notifyError(error)
|
|
18
|
+
}
|
|
19
|
+
}),
|
|
20
|
+
mutationCache: new MutationCache({ onError: notifyError }),
|
|
21
|
+
defaultOptions: {
|
|
22
|
+
queries: {
|
|
23
|
+
staleTime: 60_000,
|
|
24
|
+
retry: (failureCount, error) => !isClientError(error) && failureCount < 2
|
|
25
|
+
},
|
|
26
|
+
mutations: {
|
|
27
|
+
retry: false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { singleFlight } from '@/lib/single-flight'
|
|
2
|
+
|
|
3
|
+
const deferred = <T>() => {
|
|
4
|
+
let resolve!: (value: T) => void
|
|
5
|
+
let reject!: (reason?: unknown) => void
|
|
6
|
+
const promise = new Promise<T>((res, rej) => {
|
|
7
|
+
resolve = res
|
|
8
|
+
reject = rej
|
|
9
|
+
})
|
|
10
|
+
return { promise, resolve, reject }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
it('collapses concurrent calls into a single in-flight invocation', async () => {
|
|
14
|
+
const d = deferred<number>()
|
|
15
|
+
const fn = vi.fn(() => d.promise)
|
|
16
|
+
const wrapped = singleFlight(fn)
|
|
17
|
+
|
|
18
|
+
const a = wrapped()
|
|
19
|
+
const b = wrapped()
|
|
20
|
+
|
|
21
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
22
|
+
expect(a).toBe(b)
|
|
23
|
+
|
|
24
|
+
d.resolve(42)
|
|
25
|
+
await expect(a).resolves.toBe(42)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('re-invokes after the previous call settles', async () => {
|
|
29
|
+
const fn = vi.fn(() => Promise.resolve('ok'))
|
|
30
|
+
const wrapped = singleFlight(fn)
|
|
31
|
+
|
|
32
|
+
await wrapped()
|
|
33
|
+
await wrapped()
|
|
34
|
+
|
|
35
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('clears the pending slot on rejection so the next call retries', async () => {
|
|
39
|
+
const fn = vi
|
|
40
|
+
.fn<() => Promise<string>>()
|
|
41
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
42
|
+
.mockResolvedValueOnce('recovered')
|
|
43
|
+
const wrapped = singleFlight(fn)
|
|
44
|
+
|
|
45
|
+
await expect(wrapped()).rejects.toThrow('boom')
|
|
46
|
+
await expect(wrapped()).resolves.toBe('recovered')
|
|
47
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
48
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cn, type ClassValue } from 'cnfast'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { RouterProvider } from '@tanstack/react-router'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
|
|
4
|
+
import { mocksEnabled } from '@/env'
|
|
5
|
+
import { Providers } from '@/providers'
|
|
6
|
+
import { router } from '@/router'
|
|
7
|
+
|
|
8
|
+
import '@/index.css'
|
|
9
|
+
|
|
10
|
+
// `import.meta.env.DEV` must be the first operand: it is replaced with a literal
|
|
11
|
+
// at build time, so the whole branch — including the dynamic import — is dropped
|
|
12
|
+
// from production builds. `mocksEnabled` alone is a runtime value, and Vite would
|
|
13
|
+
// still emit the mock backend as a lazy chunk.
|
|
14
|
+
if (import.meta.env.DEV && mocksEnabled) {
|
|
15
|
+
const { installMockServer } = await import('@/mocks/mock-server')
|
|
16
|
+
installMockServer()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const rootEl = document.getElementById('root')
|
|
20
|
+
if (!rootEl) throw new Error('#root not found')
|
|
21
|
+
|
|
22
|
+
createRoot(rootEl).render(
|
|
23
|
+
<Providers>
|
|
24
|
+
<RouterProvider router={router} />
|
|
25
|
+
</Providers>
|
|
26
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throwaway in-memory backend for local dev. Patches global `fetch` for `/api/*`.
|
|
3
|
+
*
|
|
4
|
+
* TO REMOVE: delete `src/mocks/`, drop the `mocksEnabled` block in `src/main.tsx`,
|
|
5
|
+
* and remove `VITE_ENABLE_MOCKS` from `src/env.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Seed login — email: demo@example.com password: demo1234
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Session, Tokens, User } from '@/api/auth/schema'
|
|
11
|
+
|
|
12
|
+
// Latency makes loading states visible during development. In tests it is pure
|
|
13
|
+
// waiting: it accounted for 5.8s of a 5.9s suite, which slows the Stop-hook gate
|
|
14
|
+
// for no signal. `MODE` is statically replaced, so this costs nothing at runtime.
|
|
15
|
+
const LATENCY_MS = import.meta.env.MODE === 'test' ? 0 : 300
|
|
16
|
+
const ACCESS_TTL_MS = 15 * 60 * 1000
|
|
17
|
+
|
|
18
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
19
|
+
const nonce = () => Math.random().toString(36).slice(2)
|
|
20
|
+
|
|
21
|
+
type MockUser = { id: string; email: string; password: string }
|
|
22
|
+
|
|
23
|
+
const users = new Map<string, MockUser>([
|
|
24
|
+
['demo@example.com', { id: 'u_demo', email: 'demo@example.com', password: 'demo1234' }]
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const validRefreshTokens = new Set<string>()
|
|
28
|
+
|
|
29
|
+
const issueAccess = (userId: string) => `mock.access.${userId}.${Date.now() + ACCESS_TTL_MS}`
|
|
30
|
+
|
|
31
|
+
const issueRefresh = (userId: string) => {
|
|
32
|
+
const token = `mock.refresh.${userId}.${nonce()}`
|
|
33
|
+
validRefreshTokens.add(token)
|
|
34
|
+
return token
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const userIdFromAccess = (token: string | null): string | null => {
|
|
38
|
+
const [kind, sub, userId, exp] = token?.split('.') ?? []
|
|
39
|
+
if (kind !== 'mock' || sub !== 'access' || !userId) return null
|
|
40
|
+
return Number(exp) > Date.now() ? userId : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `satisfies` ties the mock's wire shapes to src/api/auth/schema.ts: changing a
|
|
44
|
+
// schema breaks the mock at compile time instead of at the zod boundary at runtime.
|
|
45
|
+
const publicUser = (user: MockUser) => ({ id: user.id, email: user.email }) satisfies User
|
|
46
|
+
|
|
47
|
+
const json = (data: unknown, status = 200) =>
|
|
48
|
+
new Response(JSON.stringify(data), {
|
|
49
|
+
status,
|
|
50
|
+
headers: { 'content-type': 'application/json' }
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
type MockRequest = { method: string; path: string; bearer: string | null; body: unknown }
|
|
54
|
+
|
|
55
|
+
const handle = async ({ method, path, bearer, body }: MockRequest): Promise<Response> => {
|
|
56
|
+
const route = `${method} ${path}`
|
|
57
|
+
|
|
58
|
+
switch (route) {
|
|
59
|
+
case 'POST /api/auth/login': {
|
|
60
|
+
const { email, password } = (body ?? {}) as Partial<MockUser>
|
|
61
|
+
const user = email ? users.get(email) : undefined
|
|
62
|
+
if (!user || user.password !== password) return json({ message: 'Invalid credentials' }, 401)
|
|
63
|
+
return json({
|
|
64
|
+
user: publicUser(user),
|
|
65
|
+
accessToken: issueAccess(user.id),
|
|
66
|
+
refreshToken: issueRefresh(user.id)
|
|
67
|
+
} satisfies Session)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case 'POST /api/auth/refresh': {
|
|
71
|
+
const { refreshToken } = (body ?? {}) as { refreshToken?: string }
|
|
72
|
+
const [kind, sub, userId] = refreshToken?.split('.') ?? []
|
|
73
|
+
if (kind !== 'mock' || sub !== 'refresh' || !userId || !validRefreshTokens.has(refreshToken!))
|
|
74
|
+
return json({ message: 'Invalid refresh token' }, 401)
|
|
75
|
+
|
|
76
|
+
validRefreshTokens.delete(refreshToken!)
|
|
77
|
+
return json({
|
|
78
|
+
accessToken: issueAccess(userId),
|
|
79
|
+
refreshToken: issueRefresh(userId)
|
|
80
|
+
} satisfies Tokens)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
case 'POST /api/auth/logout': {
|
|
84
|
+
const userId = userIdFromAccess(bearer)
|
|
85
|
+
if (userId) {
|
|
86
|
+
for (const t of validRefreshTokens)
|
|
87
|
+
if (t.startsWith(`mock.refresh.${userId}.`)) validRefreshTokens.delete(t)
|
|
88
|
+
}
|
|
89
|
+
return new Response(null, { status: 204 })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case 'GET /api/auth/me': {
|
|
93
|
+
const userId = userIdFromAccess(bearer)
|
|
94
|
+
const user = [...users.values()].find(u => u.id === userId)
|
|
95
|
+
if (!user) return json({ message: 'Unauthorized' }, 401)
|
|
96
|
+
return json(publicUser(user))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
default:
|
|
100
|
+
return json({ message: `No mock handler for ${route}` }, 404)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// clone-based reads leave the caller's original Request untouched, so ky can still retry it
|
|
105
|
+
const normalize = async (
|
|
106
|
+
input: RequestInfo | URL,
|
|
107
|
+
init: RequestInit | undefined,
|
|
108
|
+
path: string
|
|
109
|
+
): Promise<MockRequest> => {
|
|
110
|
+
const request = input instanceof Request ? input : null
|
|
111
|
+
const method = (init?.method ?? request?.method ?? 'GET').toUpperCase()
|
|
112
|
+
const headers = new Headers(init?.headers ?? request?.headers)
|
|
113
|
+
|
|
114
|
+
let raw: string | null = null
|
|
115
|
+
if (typeof init?.body === 'string') raw = init.body
|
|
116
|
+
else if (request)
|
|
117
|
+
raw = await request
|
|
118
|
+
.clone()
|
|
119
|
+
.text()
|
|
120
|
+
.catch(() => null)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
method,
|
|
124
|
+
path,
|
|
125
|
+
bearer: headers.get('authorization')?.replace('Bearer ', '') ?? null,
|
|
126
|
+
body: raw ? JSON.parse(raw) : null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const installMockServer = () => {
|
|
131
|
+
const originalFetch = globalThis.fetch
|
|
132
|
+
|
|
133
|
+
globalThis.fetch = async (input, init) => {
|
|
134
|
+
const href = input instanceof Request ? input.url : input instanceof URL ? input.href : input
|
|
135
|
+
if (!href.includes('/api/')) return originalFetch(input, init)
|
|
136
|
+
|
|
137
|
+
const { pathname } = new URL(href, globalThis.location?.origin ?? 'http://localhost')
|
|
138
|
+
if (!pathname.startsWith('/api/')) return originalFetch(input, init)
|
|
139
|
+
|
|
140
|
+
await sleep(LATENCY_MS)
|
|
141
|
+
return handle(await normalize(input, init, pathname))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.info('[mock-server] active — login demo@example.com / demo1234')
|
|
145
|
+
|
|
146
|
+
return () => {
|
|
147
|
+
globalThis.fetch = originalFetch
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PropsWithChildren } from 'react'
|
|
2
|
+
import { lazy, Suspense } from 'react'
|
|
3
|
+
|
|
4
|
+
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
5
|
+
|
|
6
|
+
import { queryClient } from '@/lib/query-client'
|
|
7
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
8
|
+
import { ThemeProvider } from './theme'
|
|
9
|
+
|
|
10
|
+
// Lazy so sonner stays out of the entry chunk (~8 kB gzip). A toast fired in the
|
|
11
|
+
// first frames renders as soon as the chunk lands; sonner queues via its store.
|
|
12
|
+
const Toaster = lazy(() => import('@/components/ui/sonner').then(m => ({ default: m.Toaster })))
|
|
13
|
+
|
|
14
|
+
export const Providers = ({ children }: PropsWithChildren) => {
|
|
15
|
+
return (
|
|
16
|
+
<QueryClientProvider client={queryClient}>
|
|
17
|
+
<ThemeProvider>
|
|
18
|
+
<TooltipProvider>
|
|
19
|
+
{children}
|
|
20
|
+
<Suspense>
|
|
21
|
+
<Toaster richColors closeButton duration={5_000} />
|
|
22
|
+
</Suspense>
|
|
23
|
+
</TooltipProvider>
|
|
24
|
+
</ThemeProvider>
|
|
25
|
+
</QueryClientProvider>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { beforeEach, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { ThemeProvider, useTheme } from '@/providers/theme'
|
|
6
|
+
|
|
7
|
+
const Probe = () => <span data-testid='theme'>{useTheme().theme}</span>
|
|
8
|
+
|
|
9
|
+
const theme = () => screen.getByTestId('theme').textContent
|
|
10
|
+
|
|
11
|
+
const writeFromAnotherTab = (value: string | null) =>
|
|
12
|
+
act(() => {
|
|
13
|
+
window.dispatchEvent(new StorageEvent('storage', { key: 'ui-theme:v1', newValue: value }))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
localStorage.clear()
|
|
18
|
+
document.documentElement.classList.remove('dark')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('adopts the stored theme on mount', () => {
|
|
22
|
+
localStorage.setItem('ui-theme:v1', 'dark')
|
|
23
|
+
render(
|
|
24
|
+
<ThemeProvider>
|
|
25
|
+
<Probe />
|
|
26
|
+
</ThemeProvider>
|
|
27
|
+
)
|
|
28
|
+
expect(theme()).toBe('dark')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('falls back to system for a value it does not recognise', () => {
|
|
32
|
+
localStorage.setItem('ui-theme:v1', 'neon')
|
|
33
|
+
render(
|
|
34
|
+
<ThemeProvider>
|
|
35
|
+
<Probe />
|
|
36
|
+
</ThemeProvider>
|
|
37
|
+
)
|
|
38
|
+
expect(theme()).toBe('system')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('follows a theme change made in another tab', () => {
|
|
42
|
+
render(
|
|
43
|
+
<ThemeProvider>
|
|
44
|
+
<Probe />
|
|
45
|
+
</ThemeProvider>
|
|
46
|
+
)
|
|
47
|
+
expect(theme()).toBe('system')
|
|
48
|
+
|
|
49
|
+
writeFromAnotherTab('dark')
|
|
50
|
+
|
|
51
|
+
expect(theme()).toBe('dark')
|
|
52
|
+
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('ignores cross-tab writes to unrelated keys', () => {
|
|
56
|
+
localStorage.setItem('ui-theme:v1', 'dark')
|
|
57
|
+
render(
|
|
58
|
+
<ThemeProvider>
|
|
59
|
+
<Probe />
|
|
60
|
+
</ThemeProvider>
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
act(() => {
|
|
64
|
+
window.dispatchEvent(new StorageEvent('storage', { key: 'session', newValue: null }))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(theme()).toBe('dark')
|
|
68
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { PropsWithChildren } from 'react'
|
|
2
|
+
import { createContext, use, useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
export type Theme = 'dark' | 'light' | 'system'
|
|
5
|
+
|
|
6
|
+
type ThemeProviderState = {
|
|
7
|
+
theme: Theme
|
|
8
|
+
setTheme: (theme: Theme) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'ui-theme:v1'
|
|
12
|
+
const DEFAULT_THEME: Theme = 'system'
|
|
13
|
+
|
|
14
|
+
const isTheme = (value: unknown): value is Theme =>
|
|
15
|
+
value === 'dark' || value === 'light' || value === 'system'
|
|
16
|
+
|
|
17
|
+
const ThemeProviderContext = createContext<ThemeProviderState | null>(null)
|
|
18
|
+
|
|
19
|
+
const applyTheme = (theme: Theme) => {
|
|
20
|
+
const resolved =
|
|
21
|
+
theme === 'system'
|
|
22
|
+
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
23
|
+
? 'dark'
|
|
24
|
+
: 'light'
|
|
25
|
+
: theme
|
|
26
|
+
|
|
27
|
+
const style = document.createElement('style')
|
|
28
|
+
style.textContent = '*,*::before,*::after{transition:none!important}'
|
|
29
|
+
document.head.appendChild(style)
|
|
30
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark')
|
|
31
|
+
|
|
32
|
+
// flush the transition-disabling style before removing it, so the theme swap doesn't animate
|
|
33
|
+
void window.getComputedStyle(document.documentElement).transition
|
|
34
|
+
setTimeout(() => style.remove(), 1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
|
38
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
39
|
+
const stored = localStorage.getItem(STORAGE_KEY)
|
|
40
|
+
return isTheme(stored) ? stored : DEFAULT_THEME
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
applyTheme(theme)
|
|
45
|
+
if (theme !== 'system') return
|
|
46
|
+
|
|
47
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
48
|
+
const onChange = () => applyTheme('system')
|
|
49
|
+
mq.addEventListener('change', onChange)
|
|
50
|
+
return () => mq.removeEventListener('change', onChange)
|
|
51
|
+
}, [theme])
|
|
52
|
+
|
|
53
|
+
// localStorage is read once on mount, but another tab can change it at any
|
|
54
|
+
// time. Without this the two tabs disagree until one of them reloads.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const onStorage = (e: StorageEvent) => {
|
|
57
|
+
if (e.key !== STORAGE_KEY) return
|
|
58
|
+
setThemeState(isTheme(e.newValue) ? e.newValue : DEFAULT_THEME)
|
|
59
|
+
}
|
|
60
|
+
window.addEventListener('storage', onStorage)
|
|
61
|
+
return () => window.removeEventListener('storage', onStorage)
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const setTheme = (next: Theme) => {
|
|
65
|
+
localStorage.setItem(STORAGE_KEY, next)
|
|
66
|
+
setThemeState(next)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return <ThemeProviderContext value={{ theme, setTheme }}>{children}</ThemeProviderContext>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const useTheme = () => {
|
|
73
|
+
const ctx = use(ThemeProviderContext)
|
|
74
|
+
if (!ctx) throw new Error('useTheme must be used within <ThemeProvider>')
|
|
75
|
+
return ctx
|
|
76
|
+
}
|