docs-i18n 0.10.0 → 0.11.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/admin/app/lib/auth-client.ts +21 -0
- package/admin/app/lib/auth.server.ts +62 -0
- package/admin/app/routeTree.gen.ts +21 -3
- package/admin/app/routes/index.tsx +68 -8
- package/admin/app/routes/login.tsx +207 -0
- package/admin/app/server.ts +23 -0
- package/admin/app/styles.css +146 -0
- package/admin/app/worker.ts +61 -0
- package/admin/dist/client/assets/auth-client-DqJZaFZR.js +3 -0
- package/admin/dist/client/assets/login-CQjkiBAF.js +1 -0
- package/admin/dist/client/assets/main-bc5tcdGg.js +18 -0
- package/admin/dist/client/assets/routes-CTPOG_v1.js +3 -0
- package/admin/dist/client/assets/{styles-DJ6QEJmN.css → styles-zS0BbO7e.css} +1 -1
- package/admin/dist/client/assets/useNavigate-CEOVvMjz.js +1 -0
- package/admin/dist/server/assets/{react-dom-CpO9xk_L.js → __tanstack-start-server-fn-resolver-N1N5mZVv.js} +83 -507
- package/admin/dist/server/assets/_tanstack-start-manifest_v-BgMVPPt2.js +21 -0
- package/admin/dist/server/assets/adapter-DjXlUL1J.js +2253 -0
- package/admin/dist/server/assets/auth-client-CoUpyQIM.js +807 -0
- package/admin/dist/server/assets/auth-dSAIPjBw.js +72 -0
- package/admin/dist/server/assets/auth.server-BLVDnTCZ.js +31352 -0
- package/admin/dist/server/assets/bun-sqlite-dialect-C8OaCWSL-BNNY-FoT.js +154 -0
- package/admin/dist/server/assets/compiled-query-CnFG_BVV.js +6967 -0
- package/admin/dist/server/assets/d1-sqlite-dialect-sYHNqBte-Bjz-cybU.js +115 -0
- package/admin/dist/server/assets/dist-C3-e8E2B.js +215 -0
- package/admin/dist/server/assets/dist-CUzFWZag.js +6039 -0
- package/admin/dist/server/assets/{init-DJr2glb3.js → dist-DBv71kqn.js} +21 -381
- package/admin/dist/server/assets/error-CASJ5tIm.js +457 -0
- package/admin/dist/server/assets/error-codes-BhMTGvV6.js +1153 -0
- package/admin/dist/server/assets/init-CJJUsPDL.js +49 -0
- package/admin/dist/server/assets/job-manager-D9Ab9hgu.js +179 -0
- package/admin/dist/server/assets/{jobs-bQfYqSk7.js → jobs-DrEe9YOj.js} +18 -5
- package/admin/dist/server/assets/kysely-adapter-YC8RFPyc.js +2 -0
- package/admin/dist/server/assets/login-SqzTMYOZ.js +228 -0
- package/admin/dist/server/assets/migrator-ZpVZslbq.js +2926 -0
- package/admin/dist/server/assets/{misc-DOk3t9vs.js → misc-BSoYldBT.js} +22 -9
- package/admin/dist/server/assets/{models-CBb8Owe5.js → models-CoviNHUP.js} +1 -1
- package/admin/dist/server/assets/node-sqlite-dialect-BJIaP6lL.js +154 -0
- package/admin/dist/server/assets/{router-DlU_fGDK.js → router-Cxm33tn3.js} +23 -11
- package/admin/dist/server/assets/{routes-pIM0fgUO.js → routes-CxGhew8Z.js} +56 -22
- package/admin/dist/server/assets/{routes-BcfX6iub.js → routes-DtWFPDUc.js} +43 -29
- package/admin/dist/server/assets/sqlite-adapter-CL2EidjD.js +69 -0
- package/admin/dist/server/assets/{start-BS52hm79.js → start-BiybVoR2.js} +1 -1
- package/admin/dist/server/assets/status-B1AGLvHn.js +162 -0
- package/admin/dist/server/assets/status-D7PU72hm.js +262 -0
- package/admin/dist/server/assets/string-B4XlckmJ.js +6 -0
- package/admin/dist/server/assets/useNavigate-0H08s_Q2.js +29 -0
- package/admin/dist/server/assets/useRouter-BXJvr8to.js +508 -0
- package/admin/dist/server/server.js +70 -90
- package/admin/package.json +6 -1
- package/admin/server/functions/auth.ts +89 -0
- package/admin/server/functions/jobs.ts +30 -2
- package/admin/server/functions/misc.ts +25 -5
- package/admin/server/functions/status.ts +52 -3
- package/admin/server/index.ts +1 -1
- package/admin/server/init.ts +21 -9
- package/admin/server/services/d1-status.ts +282 -0
- package/admin/vite.config.ts +29 -15
- package/admin/wrangler.jsonc +16 -0
- package/package.json +1 -1
- package/template/content/docs-i18n/en/cli.md +9 -5
- package/template/content/docs-i18n/en/deployment.md +85 -68
- package/admin/dist/client/assets/main-CSFhgz4p.js +0 -17
- package/admin/dist/client/assets/routes-C6bCOSX-.js +0 -3
- package/admin/dist/server/assets/_tanstack-start-manifest_v-BE5XHVZ2.js +0 -17
- package/admin/dist/server/assets/redirect-DtfSYi2g.js +0 -51
- package/admin/dist/server/assets/status-CZz8Rs_7.js +0 -81
- /package/admin/dist/server/assets/{createServerRpc-DJq9yo-B.js → createServerRpc-Cyyxq9HQ.js} +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createAuthClient } from 'better-auth/react';
|
|
2
|
+
import { usernameClient } from 'better-auth/client/plugins';
|
|
3
|
+
import { anonymousClient } from 'better-auth/client/plugins';
|
|
4
|
+
import { adminClient } from 'better-auth/client/plugins';
|
|
5
|
+
|
|
6
|
+
export const authClient = createAuthClient({
|
|
7
|
+
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3456',
|
|
8
|
+
basePath: '/api/auth',
|
|
9
|
+
plugins: [
|
|
10
|
+
usernameClient(),
|
|
11
|
+
anonymousClient(),
|
|
12
|
+
adminClient(),
|
|
13
|
+
],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const {
|
|
17
|
+
useSession,
|
|
18
|
+
signIn,
|
|
19
|
+
signUp,
|
|
20
|
+
signOut,
|
|
21
|
+
} = authClient;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { betterAuth } from 'better-auth';
|
|
2
|
+
import { username } from 'better-auth/plugins';
|
|
3
|
+
import { anonymous } from 'better-auth/plugins';
|
|
4
|
+
import { admin } from 'better-auth/plugins';
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
let _auth: any = null;
|
|
11
|
+
|
|
12
|
+
function getDbPath(): string {
|
|
13
|
+
const projectRoot = process.env.DOCS_I18N_PROJECT_ROOT || process.cwd();
|
|
14
|
+
const cacheDir = resolve(projectRoot, '.cache');
|
|
15
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
16
|
+
return resolve(cacheDir, 'auth.db');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get or create the better-auth instance (singleton).
|
|
21
|
+
* Uses better-sqlite3 stored at <projectRoot>/.cache/auth.db.
|
|
22
|
+
*/
|
|
23
|
+
export function getAuth() {
|
|
24
|
+
if (_auth) return _auth;
|
|
25
|
+
|
|
26
|
+
const dbPath = getDbPath();
|
|
27
|
+
const db = new Database(dbPath);
|
|
28
|
+
|
|
29
|
+
_auth = betterAuth({
|
|
30
|
+
database: db,
|
|
31
|
+
secret: process.env.BETTER_AUTH_SECRET || 'docs-i18n-admin-dev-secret-change-in-production',
|
|
32
|
+
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3456',
|
|
33
|
+
basePath: '/api/auth',
|
|
34
|
+
emailAndPassword: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
},
|
|
37
|
+
plugins: [
|
|
38
|
+
username(),
|
|
39
|
+
anonymous(),
|
|
40
|
+
admin({
|
|
41
|
+
defaultRole: 'user',
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
session: {
|
|
45
|
+
cookieCache: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
maxAge: 5 * 60, // 5 min
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
user: {
|
|
51
|
+
additionalFields: {
|
|
52
|
+
displayName: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
required: false,
|
|
55
|
+
defaultValue: '',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return _auth;
|
|
62
|
+
}
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
|
10
10
|
|
|
11
11
|
import { Route as rootRouteImport } from './routes/__root'
|
|
12
|
+
import { Route as LoginRouteImport } from './routes/login'
|
|
12
13
|
import { Route as IndexRouteImport } from './routes/index'
|
|
13
14
|
|
|
15
|
+
const LoginRoute = LoginRouteImport.update({
|
|
16
|
+
id: '/login',
|
|
17
|
+
path: '/login',
|
|
18
|
+
getParentRoute: () => rootRouteImport,
|
|
19
|
+
} as any)
|
|
14
20
|
const IndexRoute = IndexRouteImport.update({
|
|
15
21
|
id: '/',
|
|
16
22
|
path: '/',
|
|
@@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({
|
|
|
19
25
|
|
|
20
26
|
export interface FileRoutesByFullPath {
|
|
21
27
|
'/': typeof IndexRoute
|
|
28
|
+
'/login': typeof LoginRoute
|
|
22
29
|
}
|
|
23
30
|
export interface FileRoutesByTo {
|
|
24
31
|
'/': typeof IndexRoute
|
|
32
|
+
'/login': typeof LoginRoute
|
|
25
33
|
}
|
|
26
34
|
export interface FileRoutesById {
|
|
27
35
|
__root__: typeof rootRouteImport
|
|
28
36
|
'/': typeof IndexRoute
|
|
37
|
+
'/login': typeof LoginRoute
|
|
29
38
|
}
|
|
30
39
|
export interface FileRouteTypes {
|
|
31
40
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
32
|
-
fullPaths: '/'
|
|
41
|
+
fullPaths: '/' | '/login'
|
|
33
42
|
fileRoutesByTo: FileRoutesByTo
|
|
34
|
-
to: '/'
|
|
35
|
-
id: '__root__' | '/'
|
|
43
|
+
to: '/' | '/login'
|
|
44
|
+
id: '__root__' | '/' | '/login'
|
|
36
45
|
fileRoutesById: FileRoutesById
|
|
37
46
|
}
|
|
38
47
|
export interface RootRouteChildren {
|
|
39
48
|
IndexRoute: typeof IndexRoute
|
|
49
|
+
LoginRoute: typeof LoginRoute
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
declare module '@tanstack/react-router' {
|
|
43
53
|
interface FileRoutesByPath {
|
|
54
|
+
'/login': {
|
|
55
|
+
id: '/login'
|
|
56
|
+
path: '/login'
|
|
57
|
+
fullPath: '/login'
|
|
58
|
+
preLoaderRoute: typeof LoginRouteImport
|
|
59
|
+
parentRoute: typeof rootRouteImport
|
|
60
|
+
}
|
|
44
61
|
'/': {
|
|
45
62
|
id: '/'
|
|
46
63
|
path: '/'
|
|
@@ -53,6 +70,7 @@ declare module '@tanstack/react-router' {
|
|
|
53
70
|
|
|
54
71
|
const rootRouteChildren: RootRouteChildren = {
|
|
55
72
|
IndexRoute: IndexRoute,
|
|
73
|
+
LoginRoute: LoginRoute,
|
|
56
74
|
}
|
|
57
75
|
export const routeTree = rootRouteImport
|
|
58
76
|
._addFileChildren(rootRouteChildren)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
1
|
+
import { createFileRoute, useNavigate, redirect } from '@tanstack/react-router';
|
|
2
2
|
import { useQuery } from '@tanstack/react-query';
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { FileList } from '../components/FileList';
|
|
@@ -7,6 +7,8 @@ import { JobPanel } from '../components/JobPanel';
|
|
|
7
7
|
import { LangGrid } from '../components/LangGrid';
|
|
8
8
|
import { Preview } from '../components/Preview';
|
|
9
9
|
import { api } from '../lib/api';
|
|
10
|
+
import { authClient } from '../lib/auth-client';
|
|
11
|
+
import { getSession } from '../../server/functions/auth';
|
|
10
12
|
|
|
11
13
|
type ViewMode = 'split' | 'en' | 'lang';
|
|
12
14
|
type StatusFilter = 'all' | 'complete' | 'partial' | 'missing';
|
|
@@ -51,6 +53,25 @@ function parseProjectVersions(versionKeys: string[]) {
|
|
|
51
53
|
};
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Determine user role from session data.
|
|
58
|
+
* Anonymous users are "guest", others check the role field.
|
|
59
|
+
*/
|
|
60
|
+
function getUserRole(session: { user: Record<string, unknown> } | null): 'admin' | 'user' | 'guest' {
|
|
61
|
+
if (!session?.user) return 'guest';
|
|
62
|
+
if ((session.user as Record<string, unknown>).isAnonymous) return 'guest';
|
|
63
|
+
const role = (session.user as Record<string, unknown>).role;
|
|
64
|
+
if (role === 'admin') return 'admin';
|
|
65
|
+
return 'user';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getUserDisplayName(session: { user: Record<string, unknown> } | null): string {
|
|
69
|
+
if (!session?.user) return 'Guest';
|
|
70
|
+
const user = session.user as Record<string, unknown>;
|
|
71
|
+
if (user.isAnonymous) return 'Guest';
|
|
72
|
+
return (user.displayName as string) || (user.name as string) || (user.username as string) || (user.email as string) || 'User';
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
export const Route = createFileRoute('/')({
|
|
55
76
|
validateSearch: (search: Record<string, unknown>): AdminSearch => ({
|
|
56
77
|
project: (search.project as string) || undefined,
|
|
@@ -64,13 +85,25 @@ export const Route = createFileRoute('/')({
|
|
|
64
85
|
status: (search.status as StatusFilter) || undefined,
|
|
65
86
|
section: (search.section as SectionFilter) || undefined,
|
|
66
87
|
}),
|
|
88
|
+
beforeLoad: async () => {
|
|
89
|
+
const session = await getSession();
|
|
90
|
+
if (!session) {
|
|
91
|
+
throw redirect({ to: '/login' });
|
|
92
|
+
}
|
|
93
|
+
return { session };
|
|
94
|
+
},
|
|
67
95
|
component: AdminPage,
|
|
68
96
|
});
|
|
69
97
|
|
|
70
98
|
function AdminPage() {
|
|
71
99
|
const search = Route.useSearch();
|
|
100
|
+
const { session } = Route.useRouteContext();
|
|
72
101
|
const navigate = useNavigate({ from: '/' });
|
|
73
102
|
|
|
103
|
+
const userRole = getUserRole(session);
|
|
104
|
+
const userName = getUserDisplayName(session);
|
|
105
|
+
const isAdmin = userRole === 'admin';
|
|
106
|
+
|
|
74
107
|
const lang = search.lang || null;
|
|
75
108
|
const file = search.file || null;
|
|
76
109
|
const showFiles = search.files !== '0';
|
|
@@ -261,14 +294,27 @@ function AdminPage() {
|
|
|
261
294
|
const handleClear = useCallback(() => setSelected(new Set()), []);
|
|
262
295
|
|
|
263
296
|
const handleTranslateSelected = useCallback(() => {
|
|
297
|
+
if (!isAdmin) {
|
|
298
|
+
setToast('Only admin users can start translation jobs');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
264
301
|
setDialogFiles([...selected]);
|
|
265
302
|
setShowDialog(true);
|
|
266
|
-
}, [selected]);
|
|
303
|
+
}, [selected, isAdmin]);
|
|
267
304
|
|
|
268
305
|
const handleNewJob = useCallback(() => {
|
|
306
|
+
if (!isAdmin) {
|
|
307
|
+
setToast('Only admin users can start translation jobs');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
269
310
|
setDialogFiles(undefined);
|
|
270
311
|
setShowDialog(true);
|
|
271
|
-
}, []);
|
|
312
|
+
}, [isAdmin]);
|
|
313
|
+
|
|
314
|
+
const handleSignOut = useCallback(async () => {
|
|
315
|
+
await authClient.signOut();
|
|
316
|
+
navigate({ to: '/login' });
|
|
317
|
+
}, [navigate]);
|
|
272
318
|
|
|
273
319
|
if (!statusData) return <div className="loading">Loading...</div>;
|
|
274
320
|
|
|
@@ -276,14 +322,28 @@ function AdminPage() {
|
|
|
276
322
|
<>
|
|
277
323
|
<nav>
|
|
278
324
|
<h1>
|
|
279
|
-
{'
|
|
325
|
+
{'Translation Admin '}
|
|
280
326
|
{versionInfo?.version && (
|
|
281
327
|
<span className="version-badge">v{versionInfo.version}</span>
|
|
282
328
|
)}
|
|
283
329
|
</h1>
|
|
284
330
|
<span className="spacer" />
|
|
285
|
-
|
|
286
|
-
|
|
331
|
+
{isAdmin && (
|
|
332
|
+
<button type="button" className="btn" onClick={handleNewJob}>
|
|
333
|
+
+ New Job
|
|
334
|
+
</button>
|
|
335
|
+
)}
|
|
336
|
+
<div className="user-info">
|
|
337
|
+
<span>{userName}</span>
|
|
338
|
+
<span className={`user-role ${userRole}`}>{userRole}</span>
|
|
339
|
+
</div>
|
|
340
|
+
<button
|
|
341
|
+
type="button"
|
|
342
|
+
className="btn-signout"
|
|
343
|
+
onClick={handleSignOut}
|
|
344
|
+
title="Sign out"
|
|
345
|
+
>
|
|
346
|
+
Sign Out
|
|
287
347
|
</button>
|
|
288
348
|
<button
|
|
289
349
|
type="button"
|
|
@@ -396,8 +456,8 @@ function AdminPage() {
|
|
|
396
456
|
{/* Toast */}
|
|
397
457
|
{toast && <div className="toast">{toast}</div>}
|
|
398
458
|
|
|
399
|
-
{/* Job dialog */}
|
|
400
|
-
{showDialog && (
|
|
459
|
+
{/* Job dialog — only for admin */}
|
|
460
|
+
{showDialog && isAdmin && (
|
|
401
461
|
<JobDialog
|
|
402
462
|
langs={statusData.langs}
|
|
403
463
|
versions={statusData.versions}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { authClient } from '../lib/auth-client';
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/login')({
|
|
6
|
+
component: LoginPage,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function LoginPage() {
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const [mode, setMode] = useState<'login' | 'register'>('login');
|
|
12
|
+
const [username, setUsername] = useState('');
|
|
13
|
+
const [password, setPassword] = useState('');
|
|
14
|
+
const [email, setEmail] = useState('');
|
|
15
|
+
const [name, setName] = useState('');
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [loading, setLoading] = useState(false);
|
|
18
|
+
|
|
19
|
+
const handleLogin = async (e: React.FormEvent) => {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
setError(null);
|
|
22
|
+
setLoading(true);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const { error: authError } = await authClient.signIn.username({
|
|
26
|
+
username,
|
|
27
|
+
password,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (authError) {
|
|
31
|
+
setError(authError.message || 'Login failed');
|
|
32
|
+
setLoading(false);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
navigate({ to: '/' });
|
|
37
|
+
} catch (err: unknown) {
|
|
38
|
+
setError(err instanceof Error ? err.message : 'Login failed');
|
|
39
|
+
setLoading(false);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleRegister = async (e: React.FormEvent) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
setError(null);
|
|
46
|
+
setLoading(true);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const { error: authError } = await authClient.signUp.email({
|
|
50
|
+
email,
|
|
51
|
+
password,
|
|
52
|
+
name: name || username,
|
|
53
|
+
username,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (authError) {
|
|
57
|
+
setError(authError.message || 'Registration failed');
|
|
58
|
+
setLoading(false);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
navigate({ to: '/' });
|
|
63
|
+
} catch (err: unknown) {
|
|
64
|
+
setError(err instanceof Error ? err.message : 'Registration failed');
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleAnonymousLogin = async () => {
|
|
70
|
+
setError(null);
|
|
71
|
+
setLoading(true);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const { error: authError } = await authClient.signIn.anonymous();
|
|
75
|
+
|
|
76
|
+
if (authError) {
|
|
77
|
+
setError(authError.message || 'Anonymous login failed');
|
|
78
|
+
setLoading(false);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
navigate({ to: '/' });
|
|
83
|
+
} catch (err: unknown) {
|
|
84
|
+
setError(err instanceof Error ? err.message : 'Anonymous login failed');
|
|
85
|
+
setLoading(false);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="login-page">
|
|
91
|
+
<div className="login-card">
|
|
92
|
+
<h2>Translation Admin</h2>
|
|
93
|
+
<p className="login-subtitle">Sign in to manage translations</p>
|
|
94
|
+
|
|
95
|
+
{/* Mode toggle */}
|
|
96
|
+
<div className="login-mode-toggle">
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
className={mode === 'login' ? 'active' : ''}
|
|
100
|
+
onClick={() => { setMode('login'); setError(null); }}
|
|
101
|
+
>
|
|
102
|
+
Sign In
|
|
103
|
+
</button>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
className={mode === 'register' ? 'active' : ''}
|
|
107
|
+
onClick={() => { setMode('register'); setError(null); }}
|
|
108
|
+
>
|
|
109
|
+
Register
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{error && <div className="login-error">{error}</div>}
|
|
114
|
+
|
|
115
|
+
{mode === 'login' ? (
|
|
116
|
+
<form onSubmit={handleLogin}>
|
|
117
|
+
<label htmlFor="login-username">Username</label>
|
|
118
|
+
<input
|
|
119
|
+
id="login-username"
|
|
120
|
+
type="text"
|
|
121
|
+
value={username}
|
|
122
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
123
|
+
placeholder="admin"
|
|
124
|
+
required
|
|
125
|
+
autoComplete="username"
|
|
126
|
+
/>
|
|
127
|
+
<label htmlFor="login-password">Password</label>
|
|
128
|
+
<input
|
|
129
|
+
id="login-password"
|
|
130
|
+
type="password"
|
|
131
|
+
value={password}
|
|
132
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
133
|
+
placeholder="password"
|
|
134
|
+
required
|
|
135
|
+
autoComplete="current-password"
|
|
136
|
+
/>
|
|
137
|
+
<button type="submit" className="btn login-btn" disabled={loading}>
|
|
138
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
139
|
+
</button>
|
|
140
|
+
</form>
|
|
141
|
+
) : (
|
|
142
|
+
<form onSubmit={handleRegister}>
|
|
143
|
+
<label htmlFor="reg-username">Username</label>
|
|
144
|
+
<input
|
|
145
|
+
id="reg-username"
|
|
146
|
+
type="text"
|
|
147
|
+
value={username}
|
|
148
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
149
|
+
placeholder="username"
|
|
150
|
+
required
|
|
151
|
+
autoComplete="username"
|
|
152
|
+
/>
|
|
153
|
+
<label htmlFor="reg-name">Display Name</label>
|
|
154
|
+
<input
|
|
155
|
+
id="reg-name"
|
|
156
|
+
type="text"
|
|
157
|
+
value={name}
|
|
158
|
+
onChange={(e) => setName(e.target.value)}
|
|
159
|
+
placeholder="John Doe"
|
|
160
|
+
autoComplete="name"
|
|
161
|
+
/>
|
|
162
|
+
<label htmlFor="reg-email">Email</label>
|
|
163
|
+
<input
|
|
164
|
+
id="reg-email"
|
|
165
|
+
type="email"
|
|
166
|
+
value={email}
|
|
167
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
168
|
+
placeholder="user@example.com"
|
|
169
|
+
required
|
|
170
|
+
autoComplete="email"
|
|
171
|
+
/>
|
|
172
|
+
<label htmlFor="reg-password">Password</label>
|
|
173
|
+
<input
|
|
174
|
+
id="reg-password"
|
|
175
|
+
type="password"
|
|
176
|
+
value={password}
|
|
177
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
178
|
+
placeholder="password"
|
|
179
|
+
required
|
|
180
|
+
minLength={4}
|
|
181
|
+
autoComplete="new-password"
|
|
182
|
+
/>
|
|
183
|
+
<button type="submit" className="btn login-btn" disabled={loading}>
|
|
184
|
+
{loading ? 'Creating account...' : 'Create Account'}
|
|
185
|
+
</button>
|
|
186
|
+
</form>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
<div className="login-divider">
|
|
190
|
+
<span>or</span>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
className="btn btn-outline login-btn"
|
|
196
|
+
onClick={handleAnonymousLogin}
|
|
197
|
+
disabled={loading}
|
|
198
|
+
>
|
|
199
|
+
Continue as Guest
|
|
200
|
+
</button>
|
|
201
|
+
<p className="login-hint">
|
|
202
|
+
Guests can view translations but cannot make changes.
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom server entry that wraps TanStack Start's default handler
|
|
3
|
+
* to add better-auth API route handling at /api/auth/*.
|
|
4
|
+
*/
|
|
5
|
+
import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server';
|
|
6
|
+
import { getAuth } from './lib/auth.server';
|
|
7
|
+
|
|
8
|
+
const startHandler = createStartHandler(defaultStreamHandler);
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
async fetch(request: Request) {
|
|
12
|
+
const url = new URL(request.url);
|
|
13
|
+
|
|
14
|
+
// Intercept /api/auth/* requests and forward to better-auth
|
|
15
|
+
if (url.pathname.startsWith('/api/auth')) {
|
|
16
|
+
const auth = getAuth();
|
|
17
|
+
return auth.handler(request);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Everything else goes to TanStack Start
|
|
21
|
+
return startHandler(request);
|
|
22
|
+
},
|
|
23
|
+
};
|
package/admin/app/styles.css
CHANGED
|
@@ -1140,3 +1140,149 @@ a.preview-filename:hover {
|
|
|
1140
1140
|
.log-copy:hover {
|
|
1141
1141
|
background: var(--hover);
|
|
1142
1142
|
}
|
|
1143
|
+
|
|
1144
|
+
/* ── Login page ── */
|
|
1145
|
+
.login-page {
|
|
1146
|
+
display: flex;
|
|
1147
|
+
align-items: center;
|
|
1148
|
+
justify-content: center;
|
|
1149
|
+
min-height: 100vh;
|
|
1150
|
+
padding: 2rem;
|
|
1151
|
+
}
|
|
1152
|
+
.login-card {
|
|
1153
|
+
background: var(--card);
|
|
1154
|
+
border: 1px solid var(--border);
|
|
1155
|
+
border-radius: 0.75rem;
|
|
1156
|
+
padding: 2rem;
|
|
1157
|
+
width: 380px;
|
|
1158
|
+
max-width: 100%;
|
|
1159
|
+
}
|
|
1160
|
+
.login-card h2 {
|
|
1161
|
+
font-size: 1.3rem;
|
|
1162
|
+
margin-bottom: 0.25rem;
|
|
1163
|
+
text-align: center;
|
|
1164
|
+
}
|
|
1165
|
+
.login-subtitle {
|
|
1166
|
+
text-align: center;
|
|
1167
|
+
color: var(--fg2);
|
|
1168
|
+
font-size: 0.85rem;
|
|
1169
|
+
margin-bottom: 1.25rem;
|
|
1170
|
+
}
|
|
1171
|
+
.login-mode-toggle {
|
|
1172
|
+
display: flex;
|
|
1173
|
+
gap: 0;
|
|
1174
|
+
margin-bottom: 1rem;
|
|
1175
|
+
border: 1px solid var(--border);
|
|
1176
|
+
border-radius: 0.375rem;
|
|
1177
|
+
overflow: hidden;
|
|
1178
|
+
}
|
|
1179
|
+
.login-mode-toggle button {
|
|
1180
|
+
flex: 1;
|
|
1181
|
+
padding: 0.5rem;
|
|
1182
|
+
border: none;
|
|
1183
|
+
background: transparent;
|
|
1184
|
+
color: var(--fg2);
|
|
1185
|
+
cursor: pointer;
|
|
1186
|
+
font-size: 0.85rem;
|
|
1187
|
+
font-weight: 600;
|
|
1188
|
+
transition: background 0.15s, color 0.15s;
|
|
1189
|
+
}
|
|
1190
|
+
.login-mode-toggle button.active {
|
|
1191
|
+
background: var(--accent);
|
|
1192
|
+
color: var(--btn-fg);
|
|
1193
|
+
}
|
|
1194
|
+
.login-card label {
|
|
1195
|
+
display: block;
|
|
1196
|
+
font-size: 0.8rem;
|
|
1197
|
+
color: var(--fg2);
|
|
1198
|
+
margin: 0.75rem 0 0.25rem;
|
|
1199
|
+
}
|
|
1200
|
+
.login-card input {
|
|
1201
|
+
width: 100%;
|
|
1202
|
+
background: var(--bg);
|
|
1203
|
+
color: var(--fg);
|
|
1204
|
+
border: 1px solid var(--border);
|
|
1205
|
+
padding: 0.55rem 0.75rem;
|
|
1206
|
+
border-radius: 0.375rem;
|
|
1207
|
+
font-size: 0.85rem;
|
|
1208
|
+
outline: none;
|
|
1209
|
+
}
|
|
1210
|
+
.login-card input:focus {
|
|
1211
|
+
border-color: var(--accent);
|
|
1212
|
+
}
|
|
1213
|
+
.login-btn {
|
|
1214
|
+
width: 100%;
|
|
1215
|
+
margin-top: 1rem;
|
|
1216
|
+
padding: 0.6rem;
|
|
1217
|
+
font-size: 0.9rem;
|
|
1218
|
+
}
|
|
1219
|
+
.login-error {
|
|
1220
|
+
background: color-mix(in srgb, var(--red) 10%, transparent);
|
|
1221
|
+
color: var(--red);
|
|
1222
|
+
padding: 0.5rem 0.75rem;
|
|
1223
|
+
border-radius: 0.375rem;
|
|
1224
|
+
font-size: 0.8rem;
|
|
1225
|
+
margin-bottom: 0.75rem;
|
|
1226
|
+
}
|
|
1227
|
+
.login-divider {
|
|
1228
|
+
display: flex;
|
|
1229
|
+
align-items: center;
|
|
1230
|
+
gap: 0.75rem;
|
|
1231
|
+
margin: 1.25rem 0;
|
|
1232
|
+
color: var(--fg2);
|
|
1233
|
+
font-size: 0.8rem;
|
|
1234
|
+
}
|
|
1235
|
+
.login-divider::before,
|
|
1236
|
+
.login-divider::after {
|
|
1237
|
+
content: '';
|
|
1238
|
+
flex: 1;
|
|
1239
|
+
height: 1px;
|
|
1240
|
+
background: var(--border);
|
|
1241
|
+
}
|
|
1242
|
+
.login-hint {
|
|
1243
|
+
text-align: center;
|
|
1244
|
+
color: var(--fg2);
|
|
1245
|
+
font-size: 0.75rem;
|
|
1246
|
+
margin-top: 0.75rem;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/* ── User info in nav ── */
|
|
1250
|
+
.user-info {
|
|
1251
|
+
display: flex;
|
|
1252
|
+
align-items: center;
|
|
1253
|
+
gap: 0.5rem;
|
|
1254
|
+
font-size: 0.8rem;
|
|
1255
|
+
color: var(--fg2);
|
|
1256
|
+
}
|
|
1257
|
+
.user-role {
|
|
1258
|
+
font-size: 0.65rem;
|
|
1259
|
+
padding: 0.1rem 0.4rem;
|
|
1260
|
+
border-radius: 0.25rem;
|
|
1261
|
+
font-weight: 600;
|
|
1262
|
+
text-transform: uppercase;
|
|
1263
|
+
}
|
|
1264
|
+
.user-role.admin {
|
|
1265
|
+
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
|
1266
|
+
color: var(--accent);
|
|
1267
|
+
}
|
|
1268
|
+
.user-role.user {
|
|
1269
|
+
background: color-mix(in srgb, var(--green) 20%, transparent);
|
|
1270
|
+
color: var(--green);
|
|
1271
|
+
}
|
|
1272
|
+
.user-role.guest {
|
|
1273
|
+
background: color-mix(in srgb, var(--yellow) 20%, transparent);
|
|
1274
|
+
color: var(--yellow);
|
|
1275
|
+
}
|
|
1276
|
+
.btn-signout {
|
|
1277
|
+
background: transparent;
|
|
1278
|
+
border: 1px solid var(--border);
|
|
1279
|
+
color: var(--fg2);
|
|
1280
|
+
padding: 0.3rem 0.6rem;
|
|
1281
|
+
border-radius: 0.25rem;
|
|
1282
|
+
cursor: pointer;
|
|
1283
|
+
font-size: 0.75rem;
|
|
1284
|
+
}
|
|
1285
|
+
.btn-signout:hover {
|
|
1286
|
+
border-color: var(--red);
|
|
1287
|
+
color: var(--red);
|
|
1288
|
+
}
|