@orhancodestudio/ocsm-core 0.1.0-alpha.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +47 -0
  3. package/package.json +53 -0
  4. package/src/admin/admin.module.css +1312 -0
  5. package/src/admin/admin.types.ts +85 -0
  6. package/src/admin/components/access-denied.tsx +12 -0
  7. package/src/admin/components/admin-shell.tsx +168 -0
  8. package/src/admin/components/content-list-view.tsx +83 -0
  9. package/src/admin/components/dashboard-view.tsx +113 -0
  10. package/src/admin/components/data-table.tsx +80 -0
  11. package/src/admin/components/document-delete-button.tsx +44 -0
  12. package/src/admin/components/icons.tsx +150 -0
  13. package/src/admin/components/modal.tsx +78 -0
  14. package/src/admin/components/page-builder.tsx +1334 -0
  15. package/src/admin/components/settings-view.tsx +334 -0
  16. package/src/admin/components/sign-out-button.tsx +22 -0
  17. package/src/admin/components/system-view.tsx +77 -0
  18. package/src/admin/components/users-panel.tsx +321 -0
  19. package/src/admin/index.ts +20 -0
  20. package/src/admin/ocsm-admin.tsx +259 -0
  21. package/src/auth/authenticate.ts +76 -0
  22. package/src/auth/index.ts +9 -0
  23. package/src/auth/password.ts +22 -0
  24. package/src/auth/permissions.ts +27 -0
  25. package/src/auth/session.ts +103 -0
  26. package/src/blocks/block-renderer.tsx +428 -0
  27. package/src/blocks/block.types.ts +401 -0
  28. package/src/blocks/index.ts +15 -0
  29. package/src/blocks/markdown.tsx +11 -0
  30. package/src/config/config.schema.ts +28 -0
  31. package/src/config/config.types.ts +16 -0
  32. package/src/config/define-config.ts +19 -0
  33. package/src/config/index.ts +13 -0
  34. package/src/config/resolve-config.ts +10 -0
  35. package/src/content/content-repository.ts +66 -0
  36. package/src/content/content-store.interface.ts +23 -0
  37. package/src/content/content.types.ts +25 -0
  38. package/src/content/create-content-store.ts +18 -0
  39. package/src/content/frontmatter.ts +25 -0
  40. package/src/content/index.ts +12 -0
  41. package/src/index.ts +10 -0
  42. package/src/layout/index.ts +1 -0
  43. package/src/layout/layout-store.ts +27 -0
  44. package/src/roles/index.ts +10 -0
  45. package/src/roles/role-store.ts +95 -0
  46. package/src/roles/role.types.ts +86 -0
  47. package/src/server/create-ocsm.ts +67 -0
  48. package/src/server/documents.ts +28 -0
  49. package/src/server/index.ts +59 -0
  50. package/src/server/render-mdx.tsx +14 -0
  51. package/src/storage/create-file-backend.ts +26 -0
  52. package/src/storage/file-backend.ts +26 -0
  53. package/src/storage/fs-file-backend.ts +43 -0
  54. package/src/storage/github-file-backend.ts +97 -0
  55. package/src/storage/index.ts +8 -0
  56. package/src/storage/json-store.ts +23 -0
  57. package/src/theme/css.ts +28 -0
  58. package/src/theme/index.ts +8 -0
  59. package/src/theme/theme-store.ts +19 -0
  60. package/src/theme/theme.types.ts +53 -0
  61. package/src/types/css-modules.d.ts +4 -0
  62. package/src/update/check-for-updates.ts +50 -0
  63. package/src/update/index.ts +1 -0
  64. package/src/users/index.ts +6 -0
  65. package/src/users/user-store.ts +120 -0
  66. package/src/users/user.types.ts +18 -0
  67. package/src/version.ts +11 -0
@@ -0,0 +1,321 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { type FormEvent, useState, useTransition } from "react";
5
+ import type { PublicUser } from "../../users/user.types";
6
+ import type {
7
+ CreateUserAction,
8
+ DeleteUserAction,
9
+ UpdateUserAction,
10
+ } from "../admin.types";
11
+ import styles from "../admin.module.css";
12
+ import { DataTable, type DataTableColumn } from "./data-table";
13
+ import { IconPencil, IconPlus, IconTrash } from "./icons";
14
+ import { Modal } from "./modal";
15
+
16
+ export interface RoleOption {
17
+ id: string;
18
+ name: string;
19
+ }
20
+
21
+ export interface UsersPanelProps {
22
+ users: PublicUser[];
23
+ roles: RoleOption[];
24
+ currentUserId: string;
25
+ createUser: CreateUserAction;
26
+ updateUser: UpdateUserAction;
27
+ deleteUser: DeleteUserAction;
28
+ }
29
+
30
+ type Dialog =
31
+ | { mode: "create" }
32
+ | { mode: "edit"; user: PublicUser }
33
+ | null;
34
+
35
+ export function UsersPanel({
36
+ users,
37
+ roles,
38
+ currentUserId,
39
+ createUser,
40
+ updateUser,
41
+ deleteUser,
42
+ }: UsersPanelProps) {
43
+ const router = useRouter();
44
+ const [dialog, setDialog] = useState<Dialog>(null);
45
+ const [pending, startTransition] = useTransition();
46
+
47
+ const roleName = (id: string) =>
48
+ roles.find((role) => role.id === id)?.name ?? id;
49
+
50
+ function onDelete(user: PublicUser) {
51
+ if (!window.confirm(`"${user.name}" kullanıcısı silinsin mi?`)) return;
52
+ startTransition(async () => {
53
+ const result = await deleteUser(user.id);
54
+ if (!result.ok && result.message) window.alert(result.message);
55
+ router.refresh();
56
+ });
57
+ }
58
+
59
+ const columns: DataTableColumn<PublicUser>[] = [
60
+ {
61
+ key: "name",
62
+ header: "Kullanıcı",
63
+ cell: (user) => (
64
+ <>
65
+ {user.name}
66
+ {user.id === currentUserId ? (
67
+ <span className={styles.badge} style={{ marginLeft: 8 }}>
68
+ sen
69
+ </span>
70
+ ) : null}
71
+ </>
72
+ ),
73
+ },
74
+ {
75
+ key: "username",
76
+ header: "Kullanıcı adı",
77
+ cell: (user) => <span className={styles.badge}>@{user.username}</span>,
78
+ },
79
+ {
80
+ key: "role",
81
+ header: "Rol",
82
+ cell: (user) => (
83
+ <span className={styles.roleBadge + " " + styles.roleAdmin}>
84
+ {roleName(user.role)}
85
+ </span>
86
+ ),
87
+ },
88
+ {
89
+ key: "actions",
90
+ header: "",
91
+ align: "right",
92
+ cell: (user) => (
93
+ <div className={styles.tableActions}>
94
+ <button
95
+ type="button"
96
+ className={`${styles.btn} ${styles.btnSm}`}
97
+ onClick={() => setDialog({ mode: "edit", user })}
98
+ title="Düzenle"
99
+ >
100
+ <IconPencil width={14} height={14} />
101
+ </button>
102
+ <button
103
+ type="button"
104
+ className={`${styles.btn} ${styles.btnSm} ${styles.btnDanger}`}
105
+ onClick={() => onDelete(user)}
106
+ disabled={user.id === currentUserId}
107
+ title={user.id === currentUserId ? "Kendini silemezsin" : "Sil"}
108
+ >
109
+ <IconTrash width={14} height={14} />
110
+ </button>
111
+ </div>
112
+ ),
113
+ },
114
+ ];
115
+
116
+ return (
117
+ <div>
118
+ <div className={styles.pageHeader}>
119
+ <div>
120
+ <h1 className={styles.pageTitle}>Kullanıcılar</h1>
121
+ <p className={styles.pageSubtitle}>{users.length} kullanıcı</p>
122
+ </div>
123
+ <button
124
+ type="button"
125
+ className={`${styles.btn} ${styles.btnPrimary}`}
126
+ onClick={() => setDialog({ mode: "create" })}
127
+ >
128
+ <IconPlus width={16} height={16} />
129
+ Yeni kullanıcı
130
+ </button>
131
+ </div>
132
+
133
+ <DataTable
134
+ columns={columns}
135
+ rows={users}
136
+ rowKey={(user) => user.id}
137
+ emptyText="Henüz kullanıcı yok."
138
+ />
139
+
140
+ {dialog?.mode === "create" ? (
141
+ <UserDialog
142
+ title="Yeni kullanıcı"
143
+ roles={roles}
144
+ pending={pending}
145
+ onClose={() => setDialog(null)}
146
+ onSubmit={(values, done) =>
147
+ startTransition(async () => {
148
+ const result = await createUser({
149
+ username: values.username,
150
+ name: values.name,
151
+ role: values.role,
152
+ password: values.password,
153
+ });
154
+ done(result.ok, result.message);
155
+ if (result.ok) {
156
+ setDialog(null);
157
+ router.refresh();
158
+ }
159
+ })
160
+ }
161
+ />
162
+ ) : null}
163
+
164
+ {dialog?.mode === "edit" ? (
165
+ <UserDialog
166
+ title="Kullanıcıyı düzenle"
167
+ roles={roles}
168
+ pending={pending}
169
+ initial={dialog.user}
170
+ onClose={() => setDialog(null)}
171
+ onSubmit={(values, done) =>
172
+ startTransition(async () => {
173
+ const result = await updateUser({
174
+ id: dialog.user.id,
175
+ name: values.name,
176
+ role: values.role,
177
+ password: values.password || undefined,
178
+ });
179
+ done(result.ok, result.message);
180
+ if (result.ok) {
181
+ setDialog(null);
182
+ router.refresh();
183
+ }
184
+ })
185
+ }
186
+ />
187
+ ) : null}
188
+ </div>
189
+ );
190
+ }
191
+
192
+ interface UserFormValues {
193
+ username: string;
194
+ name: string;
195
+ role: string;
196
+ password: string;
197
+ }
198
+
199
+ function UserDialog({
200
+ title,
201
+ roles,
202
+ initial,
203
+ pending,
204
+ onClose,
205
+ onSubmit,
206
+ }: {
207
+ title: string;
208
+ roles: RoleOption[];
209
+ initial?: PublicUser;
210
+ pending: boolean;
211
+ onClose: () => void;
212
+ onSubmit: (
213
+ values: UserFormValues,
214
+ done: (ok: boolean, message?: string) => void,
215
+ ) => void;
216
+ }) {
217
+ const isEdit = !!initial;
218
+ const [username, setUsername] = useState(initial?.username ?? "");
219
+ const [name, setName] = useState(initial?.name ?? "");
220
+ const [role, setRole] = useState(initial?.role ?? roles[0]?.id ?? "");
221
+ const [password, setPassword] = useState("");
222
+ const [error, setError] = useState<string | null>(null);
223
+
224
+ function submit(event: FormEvent) {
225
+ event.preventDefault();
226
+ setError(null);
227
+ onSubmit({ username, name, role, password }, (ok, message) => {
228
+ if (!ok) setError(message ?? "İşlem başarısız");
229
+ });
230
+ }
231
+
232
+ return (
233
+ <Modal
234
+ open
235
+ title={title}
236
+ onClose={onClose}
237
+ footer={
238
+ <>
239
+ <button
240
+ type="button"
241
+ className={styles.btn}
242
+ onClick={onClose}
243
+ disabled={pending}
244
+ >
245
+ Vazgeç
246
+ </button>
247
+ <button
248
+ type="submit"
249
+ form="ocsm-user-form"
250
+ className={`${styles.btn} ${styles.btnPrimary}`}
251
+ disabled={pending}
252
+ >
253
+ {pending ? "Kaydediliyor…" : "Kaydet"}
254
+ </button>
255
+ </>
256
+ }
257
+ >
258
+ <form id="ocsm-user-form" onSubmit={submit}>
259
+ {!isEdit ? (
260
+ <div className={styles.field}>
261
+ <label className={styles.label} htmlFor="u-username">
262
+ Kullanıcı adı
263
+ </label>
264
+ <input
265
+ id="u-username"
266
+ className={styles.input}
267
+ value={username}
268
+ onChange={(e) => setUsername(e.target.value)}
269
+ autoComplete="off"
270
+ required
271
+ />
272
+ </div>
273
+ ) : null}
274
+ <div className={styles.field}>
275
+ <label className={styles.label} htmlFor="u-name">
276
+ Ad Soyad
277
+ </label>
278
+ <input
279
+ id="u-name"
280
+ className={styles.input}
281
+ value={name}
282
+ onChange={(e) => setName(e.target.value)}
283
+ required
284
+ />
285
+ </div>
286
+ <div className={styles.field}>
287
+ <label className={styles.label} htmlFor="u-role">
288
+ Rol
289
+ </label>
290
+ <select
291
+ id="u-role"
292
+ className={styles.select}
293
+ value={role}
294
+ onChange={(e) => setRole(e.target.value)}
295
+ >
296
+ {roles.map((r) => (
297
+ <option key={r.id} value={r.id}>
298
+ {r.name}
299
+ </option>
300
+ ))}
301
+ </select>
302
+ </div>
303
+ <div className={styles.field}>
304
+ <label className={styles.label} htmlFor="u-password">
305
+ {isEdit ? "Yeni şifre (boş bırakılırsa değişmez)" : "Şifre"}
306
+ </label>
307
+ <input
308
+ id="u-password"
309
+ type="password"
310
+ className={styles.input}
311
+ value={password}
312
+ onChange={(e) => setPassword(e.target.value)}
313
+ autoComplete="new-password"
314
+ required={!isEdit}
315
+ />
316
+ </div>
317
+ {error ? <p className={styles.errorText}>{error}</p> : null}
318
+ </form>
319
+ </Modal>
320
+ );
321
+ }
@@ -0,0 +1,20 @@
1
+ export { OcsmAdmin, type OcsmAdminProps } from "./ocsm-admin";
2
+ export type {
3
+ ActionResult,
4
+ CreateRoleAction,
5
+ CreateUserAction,
6
+ CreateUserActionInput,
7
+ DeleteDocumentAction,
8
+ DeleteRoleAction,
9
+ DeleteUserAction,
10
+ OcsmAdminActions,
11
+ RoleActionInput,
12
+ SaveDocumentAction,
13
+ SaveDocumentInput,
14
+ SaveLayoutAction,
15
+ SaveThemeAction,
16
+ SignOutAction,
17
+ UpdateRoleAction,
18
+ UpdateUserAction,
19
+ UpdateUserActionInput,
20
+ } from "./admin.types";
@@ -0,0 +1,259 @@
1
+ import { userHasPermission } from "../auth/permissions";
2
+ import type { SessionUser } from "../auth/session";
3
+ import { type Block, createBlock, normalizeBlocks } from "../blocks/block.types";
4
+ import type { Ocsm } from "../server/create-ocsm";
5
+ import { checkForUpdates } from "../update/check-for-updates";
6
+ import { OCSM_VERSION } from "../version";
7
+ import type { OcsmAdminActions } from "./admin.types";
8
+ import styles from "./admin.module.css";
9
+ import { AccessDenied } from "./components/access-denied";
10
+ import { AdminShell } from "./components/admin-shell";
11
+ import { ContentListView } from "./components/content-list-view";
12
+ import { DashboardView } from "./components/dashboard-view";
13
+ import { PageBuilder } from "./components/page-builder";
14
+ import { SettingsView } from "./components/settings-view";
15
+ import { SystemView } from "./components/system-view";
16
+ import { UsersPanel } from "./components/users-panel";
17
+
18
+ const RESERVED = new Set(["users", "settings", "system"]);
19
+
20
+ export interface OcsmAdminProps {
21
+ /** A configured OCS Management instance (from `createOcsm`). */
22
+ ocsm: Ocsm;
23
+ /** The authenticated user driving the panel. */
24
+ currentUser: SessionUser;
25
+ /** Catch-all route segments after `/ocsm-admin`. */
26
+ segments?: string[];
27
+ /** Server actions provided by the host app. */
28
+ actions: OcsmAdminActions;
29
+ }
30
+
31
+ /**
32
+ * The OCS Management admin panel. Render it from a catch-all route at
33
+ * `app/ocsm-admin/[[...segments]]/page.tsx`, passing the current user, your
34
+ * `ocsm` instance, and `"use server"` actions.
35
+ */
36
+ export async function OcsmAdmin({
37
+ ocsm,
38
+ currentUser,
39
+ segments = [],
40
+ actions,
41
+ }: OcsmAdminProps) {
42
+ const { brand, collections } = ocsm.config;
43
+ const canManageUsers = userHasPermission(currentUser, "users:manage");
44
+ const canManageSettings = userHasPermission(currentUser, "settings:manage");
45
+ const canDelete = userHasPermission(currentUser, "content:delete");
46
+
47
+ const update = await checkForUpdates();
48
+ const updateLatest = update.updateAvailable ? update.latest : null;
49
+ const storageBackend =
50
+ process.env.OCSM_GITHUB_TOKEN && process.env.OCSM_GITHUB_REPO
51
+ ? "github"
52
+ : "filesystem";
53
+
54
+ const [seg0, seg1] = segments;
55
+
56
+ const shell = (
57
+ active: string,
58
+ title: string,
59
+ children: React.ReactNode,
60
+ ) => (
61
+ <AdminShell
62
+ brand={brand}
63
+ collections={collections}
64
+ currentUser={currentUser}
65
+ active={active}
66
+ title={title}
67
+ canManageUsers={canManageUsers}
68
+ canManageSettings={canManageSettings}
69
+ signOut={actions.signOut}
70
+ updateLatest={updateLatest}
71
+ >
72
+ {children}
73
+ </AdminShell>
74
+ );
75
+
76
+ // --- Management sections ---
77
+ if (seg0 === "users") {
78
+ if (!canManageUsers) return shell("users", "Kullanıcılar", <AccessDenied />);
79
+ const [users, roles] = await Promise.all([
80
+ ocsm.users.list(),
81
+ ocsm.roles.list(),
82
+ ]);
83
+ return shell(
84
+ "users",
85
+ "Kullanıcılar",
86
+ <UsersPanel
87
+ users={users}
88
+ roles={roles.map((r) => ({ id: r.id, name: r.name }))}
89
+ currentUserId={currentUser.id}
90
+ createUser={actions.createUser}
91
+ updateUser={actions.updateUser}
92
+ deleteUser={actions.deleteUser}
93
+ />,
94
+ );
95
+ }
96
+
97
+ if (seg0 === "settings") {
98
+ if (!canManageSettings) {
99
+ return shell("settings", "Yapılandırma", <AccessDenied />);
100
+ }
101
+ const roles = await ocsm.roles.list();
102
+ return shell(
103
+ "settings",
104
+ "Yapılandırma",
105
+ <SettingsView
106
+ roles={roles}
107
+ createRole={actions.createRole}
108
+ updateRole={actions.updateRole}
109
+ deleteRole={actions.deleteRole}
110
+ />,
111
+ );
112
+ }
113
+
114
+ if (seg0 === "system") {
115
+ const userCount = await ocsm.users.count();
116
+ return shell(
117
+ "system",
118
+ "Sistem",
119
+ <SystemView
120
+ version={OCSM_VERSION}
121
+ updateLatest={updateLatest}
122
+ storageBackend={storageBackend}
123
+ userCount={userCount}
124
+ collectionCount={collections.length}
125
+ />,
126
+ );
127
+ }
128
+
129
+ // --- Content sections ---
130
+ const collection =
131
+ seg0 && !RESERVED.has(seg0)
132
+ ? collections.find((entry) => entry.name === seg0)
133
+ : undefined;
134
+
135
+ if (collection) {
136
+ // The page builder is a full-screen experience (rendered without the shell).
137
+ if (seg1 === "new") {
138
+ const [theme, headerBlocks, footerBlocks] = await Promise.all([
139
+ ocsm.theme.get(),
140
+ ocsm.layout.get("header"),
141
+ ocsm.layout.get("footer"),
142
+ ]);
143
+ return (
144
+ <PageBuilder
145
+ collection={collection.name}
146
+ collectionLabel={collection.label}
147
+ backHref={`/ocsm-admin/${collection.name}`}
148
+ saveAction={actions.saveDocument}
149
+ saveLayout={actions.saveLayout}
150
+ theme={theme}
151
+ initialBlocks={[]}
152
+ initialHeader={headerBlocks}
153
+ initialFooter={footerBlocks}
154
+ />
155
+ );
156
+ }
157
+
158
+ if (seg1) {
159
+ const [document, theme, headerBlocks, footerBlocks] = await Promise.all([
160
+ ocsm.getDocument(collection.name, seg1),
161
+ ocsm.theme.get(),
162
+ ocsm.layout.get("header"),
163
+ ocsm.layout.get("footer"),
164
+ ]);
165
+ return (
166
+ <PageBuilder
167
+ collection={collection.name}
168
+ collectionLabel={collection.label}
169
+ backHref={`/ocsm-admin/${collection.name}`}
170
+ saveAction={actions.saveDocument}
171
+ saveLayout={actions.saveLayout}
172
+ theme={theme}
173
+ initialSlug={seg1}
174
+ initialTitle={asString(document?.frontmatter.title)}
175
+ initialBlocks={loadInitialBlocks(
176
+ document?.frontmatter.blocks,
177
+ document?.body ?? "",
178
+ )}
179
+ initialHeader={headerBlocks}
180
+ initialFooter={footerBlocks}
181
+ />
182
+ );
183
+ }
184
+
185
+ const documents = await ocsm.listDocuments(collection.name);
186
+ return shell(
187
+ collection.name,
188
+ collection.label,
189
+ <ContentListView
190
+ collection={collection.name}
191
+ collectionLabel={collection.label}
192
+ canDelete={canDelete}
193
+ deleteAction={actions.deleteDocument}
194
+ documents={documents.map((entry) => ({
195
+ slug: entry.slug,
196
+ title: asString(entry.frontmatter.title) || entry.slug,
197
+ }))}
198
+ />,
199
+ );
200
+ }
201
+
202
+ if (seg0) {
203
+ return shell(
204
+ "dashboard",
205
+ "Bulunamadı",
206
+ <div className={styles.notice}>“{seg0}” bölümü bulunamadı.</div>,
207
+ );
208
+ }
209
+
210
+ // --- Dashboard ---
211
+ const overview = await Promise.all(
212
+ collections.map(async (entry) => ({
213
+ entry,
214
+ documents: await ocsm.listDocuments(entry.name),
215
+ })),
216
+ );
217
+
218
+ return shell(
219
+ "dashboard",
220
+ "Panel",
221
+ <DashboardView
222
+ currentUserName={currentUser.name}
223
+ version={OCSM_VERSION}
224
+ updateLatest={updateLatest}
225
+ stats={overview.map(({ entry, documents }) => ({
226
+ name: entry.name,
227
+ label: entry.label,
228
+ count: documents.length,
229
+ }))}
230
+ recent={overview
231
+ .flatMap(({ entry, documents }) =>
232
+ documents.map((document) => ({
233
+ collection: entry.name,
234
+ collectionLabel: entry.label,
235
+ slug: document.slug,
236
+ title: asString(document.frontmatter.title) || document.slug,
237
+ })),
238
+ )
239
+ .slice(0, 6)}
240
+ />,
241
+ );
242
+ }
243
+
244
+ function asString(value: unknown): string {
245
+ return typeof value === "string" ? value : "";
246
+ }
247
+
248
+ /**
249
+ * Builds the editor's initial blocks: prefers stored section blocks, and falls
250
+ * back to wrapping a legacy Markdown body in a single rich-text block.
251
+ */
252
+ function loadInitialBlocks(frontmatterBlocks: unknown, body: string): Block[] {
253
+ const blocks = normalizeBlocks(frontmatterBlocks);
254
+ if (blocks.length > 0) return blocks;
255
+ if (body.trim()) {
256
+ return [{ ...createBlock("richText"), markdown: body } as Block];
257
+ }
258
+ return [];
259
+ }
@@ -0,0 +1,76 @@
1
+ import type { RoleStore } from "../roles/role-store";
2
+ import { SYSTEM_ADMIN_ROLE_ID } from "../roles/role.types";
3
+ import type { UserStore } from "../users/user-store";
4
+ import type { OcsmUser } from "../users/user.types";
5
+ import { verifyPassword } from "./password";
6
+ import type { SessionUser } from "./session";
7
+
8
+ /**
9
+ * Verifies credentials against the user store and resolves the user's role into
10
+ * a session (name + permissions).
11
+ *
12
+ * Bootstrapping: if no users exist yet but `OCSM_ADMIN_USERNAME` and
13
+ * `OCSM_ADMIN_PASSWORD` are set, matching those credentials creates the first
14
+ * admin user and signs in.
15
+ *
16
+ * @returns the authenticated session user, or `null` on failure.
17
+ */
18
+ export async function authenticate(
19
+ users: UserStore,
20
+ roles: RoleStore,
21
+ username: string,
22
+ password: string,
23
+ ): Promise<SessionUser | null> {
24
+ if ((await users.count()) === 0) {
25
+ const seedUsername = process.env.OCSM_ADMIN_USERNAME;
26
+ const seedPassword = process.env.OCSM_ADMIN_PASSWORD;
27
+ if (
28
+ seedUsername &&
29
+ seedPassword &&
30
+ username === seedUsername &&
31
+ password === seedPassword
32
+ ) {
33
+ const created = await users.create({
34
+ username: seedUsername,
35
+ name: "Yönetici",
36
+ role: SYSTEM_ADMIN_ROLE_ID,
37
+ password: seedPassword,
38
+ });
39
+ return toSessionUser(created, roles);
40
+ }
41
+ return null;
42
+ }
43
+
44
+ const user = await users.findByUsername(username);
45
+ if (!user || !verifyPassword(password, user.passwordHash)) return null;
46
+ return toSessionUser(user, roles);
47
+ }
48
+
49
+ /**
50
+ * Creates the first admin during initial setup. Only succeeds when no users
51
+ * exist yet — otherwise returns `null`.
52
+ */
53
+ export async function setupFirstAdmin(
54
+ users: UserStore,
55
+ roles: RoleStore,
56
+ input: { username: string; name: string; password: string },
57
+ ): Promise<SessionUser | null> {
58
+ if ((await users.count()) > 0) return null;
59
+ const created = await users.create({ ...input, role: SYSTEM_ADMIN_ROLE_ID });
60
+ return toSessionUser(created, roles);
61
+ }
62
+
63
+ async function toSessionUser(
64
+ user: Pick<OcsmUser, "id" | "username" | "name" | "role">,
65
+ roles: RoleStore,
66
+ ): Promise<SessionUser> {
67
+ const resolved = await roles.resolve(user.role);
68
+ return {
69
+ id: user.id,
70
+ username: user.username,
71
+ name: user.name,
72
+ role: user.role,
73
+ roleName: resolved.name,
74
+ permissions: resolved.permissions,
75
+ };
76
+ }
@@ -0,0 +1,9 @@
1
+ export { authenticate, setupFirstAdmin } from "./authenticate";
2
+ export { hashPassword, verifyPassword } from "./password";
3
+ export { requirePermission, userHasPermission } from "./permissions";
4
+ export {
5
+ createSession,
6
+ destroySession,
7
+ getSessionUser,
8
+ type SessionUser,
9
+ } from "./session";
@@ -0,0 +1,22 @@
1
+ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
2
+
3
+ const KEY_LENGTH = 64;
4
+
5
+ /**
6
+ * Hashes a password with scrypt and a random salt. Returns a `salt:hash` string
7
+ * safe to store in a committed file.
8
+ */
9
+ export function hashPassword(password: string): string {
10
+ const salt = randomBytes(16).toString("hex");
11
+ const hash = scryptSync(password, salt, KEY_LENGTH).toString("hex");
12
+ return `${salt}:${hash}`;
13
+ }
14
+
15
+ /** Verifies a password against a `salt:hash` string in constant time. */
16
+ export function verifyPassword(password: string, stored: string): boolean {
17
+ const [salt, hash] = stored.split(":");
18
+ if (!salt || !hash) return false;
19
+ const expected = Buffer.from(hash, "hex");
20
+ const actual = scryptSync(password, salt, KEY_LENGTH);
21
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
22
+ }