@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.
- package/CHANGELOG.md +13 -0
- package/README.md +47 -0
- package/package.json +53 -0
- package/src/admin/admin.module.css +1312 -0
- package/src/admin/admin.types.ts +85 -0
- package/src/admin/components/access-denied.tsx +12 -0
- package/src/admin/components/admin-shell.tsx +168 -0
- package/src/admin/components/content-list-view.tsx +83 -0
- package/src/admin/components/dashboard-view.tsx +113 -0
- package/src/admin/components/data-table.tsx +80 -0
- package/src/admin/components/document-delete-button.tsx +44 -0
- package/src/admin/components/icons.tsx +150 -0
- package/src/admin/components/modal.tsx +78 -0
- package/src/admin/components/page-builder.tsx +1334 -0
- package/src/admin/components/settings-view.tsx +334 -0
- package/src/admin/components/sign-out-button.tsx +22 -0
- package/src/admin/components/system-view.tsx +77 -0
- package/src/admin/components/users-panel.tsx +321 -0
- package/src/admin/index.ts +20 -0
- package/src/admin/ocsm-admin.tsx +259 -0
- package/src/auth/authenticate.ts +76 -0
- package/src/auth/index.ts +9 -0
- package/src/auth/password.ts +22 -0
- package/src/auth/permissions.ts +27 -0
- package/src/auth/session.ts +103 -0
- package/src/blocks/block-renderer.tsx +428 -0
- package/src/blocks/block.types.ts +401 -0
- package/src/blocks/index.ts +15 -0
- package/src/blocks/markdown.tsx +11 -0
- package/src/config/config.schema.ts +28 -0
- package/src/config/config.types.ts +16 -0
- package/src/config/define-config.ts +19 -0
- package/src/config/index.ts +13 -0
- package/src/config/resolve-config.ts +10 -0
- package/src/content/content-repository.ts +66 -0
- package/src/content/content-store.interface.ts +23 -0
- package/src/content/content.types.ts +25 -0
- package/src/content/create-content-store.ts +18 -0
- package/src/content/frontmatter.ts +25 -0
- package/src/content/index.ts +12 -0
- package/src/index.ts +10 -0
- package/src/layout/index.ts +1 -0
- package/src/layout/layout-store.ts +27 -0
- package/src/roles/index.ts +10 -0
- package/src/roles/role-store.ts +95 -0
- package/src/roles/role.types.ts +86 -0
- package/src/server/create-ocsm.ts +67 -0
- package/src/server/documents.ts +28 -0
- package/src/server/index.ts +59 -0
- package/src/server/render-mdx.tsx +14 -0
- package/src/storage/create-file-backend.ts +26 -0
- package/src/storage/file-backend.ts +26 -0
- package/src/storage/fs-file-backend.ts +43 -0
- package/src/storage/github-file-backend.ts +97 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/json-store.ts +23 -0
- package/src/theme/css.ts +28 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/theme-store.ts +19 -0
- package/src/theme/theme.types.ts +53 -0
- package/src/types/css-modules.d.ts +4 -0
- package/src/update/check-for-updates.ts +50 -0
- package/src/update/index.ts +1 -0
- package/src/users/index.ts +6 -0
- package/src/users/user-store.ts +120 -0
- package/src/users/user.types.ts +18 -0
- 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
|
+
}
|