wepee commited on
Commit
32177e2
·
1 Parent(s): ec474ac

add: page /mahasiswa

Browse files
app/Providers/FortifyServiceProvider.php CHANGED
@@ -66,7 +66,7 @@ private function configureViews(): void
66
  'status' => $request->session()->get('status'),
67
  ]));
68
 
69
- Fortify::registerView(fn () => Inertia::render('auth/register'));
70
 
71
  Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
72
 
 
66
  'status' => $request->session()->get('status'),
67
  ]));
68
 
69
+ Fortify::registerView(fn () => Inertia::render('auth/register-choice'));
70
 
71
  Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
72
 
resources/css/app.css CHANGED
@@ -9,9 +9,8 @@
9
 
10
  @theme {
11
  --font-sans:
12
- 'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
13
- 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
14
- 'Noto Color Emoji';
15
 
16
  --radius-lg: var(--radius);
17
  --radius-md: calc(var(--radius) - 2px);
@@ -95,41 +94,169 @@ :root {
95
  --sidebar-accent-foreground: oklch(0.205 0 0);
96
  --sidebar-border: oklch(0.922 0 0);
97
  --sidebar-ring: oklch(0.87 0 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  }
99
 
100
  .dark {
101
- --background: oklch(0.145 0 0);
102
- --foreground: oklch(0.985 0 0);
103
- --card: oklch(0.145 0 0);
104
- --card-foreground: oklch(0.985 0 0);
105
- --popover: oklch(0.145 0 0);
106
- --popover-foreground: oklch(0.985 0 0);
107
- --primary: oklch(0.985 0 0);
108
- --primary-foreground: oklch(0.205 0 0);
109
- --secondary: oklch(0.269 0 0);
110
- --secondary-foreground: oklch(0.985 0 0);
111
- --muted: oklch(0.269 0 0);
112
- --muted-foreground: oklch(0.708 0 0);
113
- --accent: oklch(0.269 0 0);
114
- --accent-foreground: oklch(0.985 0 0);
115
  --destructive: oklch(0.396 0.141 25.723);
116
  --destructive-foreground: oklch(0.637 0.237 25.331);
117
- --border: oklch(0.269 0 0);
118
- --input: oklch(0.269 0 0);
119
- --ring: oklch(0.439 0 0);
120
  --chart-1: oklch(0.488 0.243 264.376);
121
  --chart-2: oklch(0.696 0.17 162.48);
122
  --chart-3: oklch(0.769 0.188 70.08);
123
  --chart-4: oklch(0.627 0.265 303.9);
124
  --chart-5: oklch(0.645 0.246 16.439);
125
- --sidebar: oklch(0.205 0 0);
126
- --sidebar-foreground: oklch(0.985 0 0);
127
- --sidebar-primary: oklch(0.985 0 0);
128
- --sidebar-primary-foreground: oklch(0.985 0 0);
129
- --sidebar-accent: oklch(0.269 0 0);
130
- --sidebar-accent-foreground: oklch(0.985 0 0);
131
- --sidebar-border: oklch(0.269 0 0);
132
- --sidebar-ring: oklch(0.439 0 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  }
134
 
135
  @layer base {
@@ -141,3 +268,603 @@ @layer base {
141
  @apply bg-background text-foreground;
142
  }
143
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  @theme {
11
  --font-sans:
12
+ 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
13
+ 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
 
14
 
15
  --radius-lg: var(--radius);
16
  --radius-md: calc(var(--radius) - 2px);
 
94
  --sidebar-accent-foreground: oklch(0.205 0 0);
95
  --sidebar-border: oklch(0.922 0 0);
96
  --sidebar-ring: oklch(0.87 0 0);
97
+
98
+ --app-bg: #fafbff;
99
+ --app-sidebar-bg: #ffffff;
100
+ --app-surface: #ffffff;
101
+ --app-surface-soft: #eef4ff;
102
+ --app-surface-muted: rgba(217, 217, 217, 0.32);
103
+ --app-border: rgba(0, 0, 0, 0.14);
104
+ --app-border-soft: rgba(0, 0, 0, 0.08);
105
+ --app-border-strong: #739af1;
106
+ --app-text: #0a0a0a;
107
+ --app-muted: #737373;
108
+ --app-primary: #2764eb;
109
+ --app-primary-soft: #ccdcff;
110
+ --app-primary-foreground: #ffffff;
111
+ --app-danger: #ef4444;
112
+ --app-placeholder: rgba(115, 115, 115, 0.42);
113
+ --app-toggle-bg: rgba(255, 255, 255, 0.9);
114
+ --app-shadow: 1px 1px 4.5px rgba(39, 100, 235, 0.36);
115
+ --app-composer-shadow: 0 10px 24px rgba(39, 100, 235, 0.28);
116
+
117
+ --welcome-shell-bg: var(--app-bg);
118
+ --welcome-title: var(--app-text);
119
+ --welcome-subtitle: var(--app-muted);
120
+ --welcome-accent: var(--app-primary);
121
+ --welcome-accent-foreground: var(--app-primary-foreground);
122
+ --welcome-accent-soft: var(--app-primary-soft);
123
+ --welcome-toggle-bg: var(--app-toggle-bg);
124
+ --welcome-card-bg: var(--app-surface);
125
+ --welcome-card-border: var(--app-border);
126
+ --welcome-card-active-border: var(--app-border-strong);
127
+ --welcome-card-active-shadow: var(--app-shadow);
128
+ --welcome-icon-muted-bg: var(--app-surface-muted);
129
+ --welcome-icon-muted-border: var(--app-border-soft);
130
+ --welcome-icon-active-border: color-mix(
131
+ in oklab,
132
+ var(--app-primary) 26%,
133
+ transparent
134
+ );
135
+ --register-shell-bg: var(--welcome-shell-bg);
136
+ --register-card-bg: var(--welcome-card-bg);
137
+ --register-card-border: var(--welcome-card-active-border);
138
+ --register-card-shadow: var(--welcome-card-active-shadow);
139
+ --register-input-bg: color-mix(
140
+ in oklab,
141
+ var(--app-primary) 13%,
142
+ var(--app-surface)
143
+ );
144
+ --register-input-border: var(--app-border-soft);
145
+ --register-button-border: var(--app-border-soft);
146
+ --register-placeholder: var(--welcome-subtitle);
147
+ --student-bg: var(--app-bg);
148
+ --student-sidebar-bg: var(--app-sidebar-bg);
149
+ --student-surface: var(--app-surface);
150
+ --student-surface-soft: var(--app-surface-soft);
151
+ --student-border: var(--app-border);
152
+ --student-border-strong: var(--app-border-strong);
153
+ --student-text: var(--app-text);
154
+ --student-muted: var(--app-muted);
155
+ --student-primary: var(--app-primary);
156
+ --student-primary-soft: var(--app-primary-soft);
157
+ --student-primary-foreground: var(--app-primary-foreground);
158
+ --student-danger: var(--app-danger);
159
+ --student-placeholder: var(--app-placeholder);
160
+ --student-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
161
+ --student-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
162
+ --student-composer-shadow: var(--app-composer-shadow);
163
  }
164
 
165
  .dark {
166
+ --background: #1f1f1f;
167
+ --foreground: #f4f4f5;
168
+ --card: #171717;
169
+ --card-foreground: #f4f4f5;
170
+ --popover: #171717;
171
+ --popover-foreground: #f4f4f5;
172
+ --primary: #3478ff;
173
+ --primary-foreground: #ffffff;
174
+ --secondary: #262626;
175
+ --secondary-foreground: #f4f4f5;
176
+ --muted: #262626;
177
+ --muted-foreground: #737373;
178
+ --accent: #262626;
179
+ --accent-foreground: #f4f4f5;
180
  --destructive: oklch(0.396 0.141 25.723);
181
  --destructive-foreground: oklch(0.637 0.237 25.331);
182
+ --border: rgba(255, 255, 255, 0.12);
183
+ --input: rgba(255, 255, 255, 0.12);
184
+ --ring: #3478ff;
185
  --chart-1: oklch(0.488 0.243 264.376);
186
  --chart-2: oklch(0.696 0.17 162.48);
187
  --chart-3: oklch(0.769 0.188 70.08);
188
  --chart-4: oklch(0.627 0.265 303.9);
189
  --chart-5: oklch(0.645 0.246 16.439);
190
+ --sidebar: #171717;
191
+ --sidebar-foreground: #f4f4f5;
192
+ --sidebar-primary: #3478ff;
193
+ --sidebar-primary-foreground: #ffffff;
194
+ --sidebar-accent: #262626;
195
+ --sidebar-accent-foreground: #f4f4f5;
196
+ --sidebar-border: rgba(255, 255, 255, 0.12);
197
+ --sidebar-ring: #3478ff;
198
+
199
+ --app-bg: #1f1f1f;
200
+ --app-sidebar-bg: #171717;
201
+ --app-surface: #171717;
202
+ --app-surface-soft: #262626;
203
+ --app-surface-muted: rgba(255, 255, 255, 0.08);
204
+ --app-border: rgba(255, 255, 255, 0.12);
205
+ --app-border-soft: rgba(255, 255, 255, 0.1);
206
+ --app-border-strong: #3478ff;
207
+ --app-text: #f4f4f5;
208
+ --app-muted: #737373;
209
+ --app-primary: #3478ff;
210
+ --app-primary-soft: #c8d7ff;
211
+ --app-primary-foreground: #ffffff;
212
+ --app-danger: #ff4545;
213
+ --app-placeholder: rgba(244, 244, 245, 0.42);
214
+ --app-toggle-bg: #171717;
215
+ --app-shadow: 0 0 20px rgba(52, 120, 255, 0.34);
216
+ --app-composer-shadow: 0 0 28px rgba(52, 120, 255, 0.36);
217
+
218
+ --welcome-shell-bg: var(--app-bg);
219
+ --welcome-title: var(--app-text);
220
+ --welcome-subtitle: var(--app-muted);
221
+ --welcome-accent: var(--app-primary);
222
+ --welcome-accent-foreground: var(--app-primary-foreground);
223
+ --welcome-accent-soft: var(--app-primary-soft);
224
+ --welcome-toggle-bg: var(--app-toggle-bg);
225
+ --welcome-card-bg: var(--app-surface);
226
+ --welcome-card-border: var(--app-border);
227
+ --welcome-card-active-border: var(--app-border-strong);
228
+ --welcome-card-active-shadow: var(--app-shadow);
229
+ --welcome-icon-muted-bg: var(--app-surface-muted);
230
+ --welcome-icon-muted-border: var(--app-border-soft);
231
+ --welcome-icon-active-border: color-mix(
232
+ in oklab,
233
+ var(--app-primary) 42%,
234
+ transparent
235
+ );
236
+ --register-shell-bg: var(--welcome-shell-bg);
237
+ --register-card-bg: var(--welcome-card-bg);
238
+ --register-card-border: var(--welcome-card-active-border);
239
+ --register-card-shadow: var(--welcome-card-active-shadow);
240
+ --register-input-bg: var(--app-surface);
241
+ --register-input-border: var(--app-border);
242
+ --register-button-border: var(--app-border);
243
+ --register-placeholder: var(--welcome-subtitle);
244
+ --student-bg: var(--app-bg);
245
+ --student-sidebar-bg: var(--app-sidebar-bg);
246
+ --student-surface: var(--app-surface);
247
+ --student-surface-soft: var(--app-surface-soft);
248
+ --student-border: var(--app-border);
249
+ --student-border-strong: var(--app-border-strong);
250
+ --student-text: var(--app-text);
251
+ --student-muted: var(--app-muted);
252
+ --student-primary: var(--app-primary);
253
+ --student-primary-soft: var(--app-primary-soft);
254
+ --student-primary-foreground: var(--app-primary-foreground);
255
+ --student-danger: var(--app-danger);
256
+ --student-placeholder: var(--app-placeholder);
257
+ --student-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
258
+ --student-sidebar-shadow: 1px 0 0 var(--app-border);
259
+ --student-composer-shadow: var(--app-composer-shadow);
260
  }
261
 
262
  @layer base {
 
268
  @apply bg-background text-foreground;
269
  }
270
  }
271
+
272
+ @layer components {
273
+ .welcome-shell {
274
+ @apply relative flex min-h-svh items-center justify-center px-4 py-8 sm:px-6;
275
+ background-color: var(--welcome-shell-bg);
276
+ }
277
+
278
+ .welcome-content {
279
+ @apply w-full max-w-[561px] space-y-7;
280
+ }
281
+
282
+ .welcome-theme-toggle {
283
+ @apply absolute top-4 right-4 h-10 w-10 rounded-[10px] shadow-none backdrop-blur sm:top-8 sm:right-8;
284
+ background-color: var(--welcome-toggle-bg);
285
+ border-color: var(--welcome-card-active-border);
286
+ color: var(--welcome-accent);
287
+ }
288
+
289
+ .welcome-logo {
290
+ @apply mx-auto flex h-[77px] w-[77px] items-center justify-center rounded-[15px] border border-white;
291
+ background-color: var(--welcome-accent);
292
+ color: var(--welcome-accent-foreground);
293
+ }
294
+
295
+ .welcome-title {
296
+ @apply text-center text-[34px] leading-tight font-bold tracking-[0.01em] sm:text-[40px];
297
+ color: var(--welcome-title);
298
+ }
299
+
300
+ .welcome-subtitle {
301
+ @apply text-center text-[15px] leading-normal sm:text-[20px];
302
+ color: var(--welcome-subtitle);
303
+ }
304
+
305
+ .welcome-entries {
306
+ @apply space-y-3;
307
+ }
308
+
309
+ .welcome-entry-card {
310
+ @apply gap-0 overflow-hidden rounded-[17px] border py-0 transition-[border-color,box-shadow];
311
+ background-color: var(--welcome-card-bg);
312
+ border-color: var(--welcome-card-border);
313
+ box-shadow: none;
314
+ }
315
+
316
+ .welcome-entry-card:hover,
317
+ .welcome-entry-card:focus-within {
318
+ border-color: var(--welcome-card-active-border);
319
+ box-shadow: var(--welcome-card-active-shadow);
320
+ }
321
+
322
+ .welcome-entry-link {
323
+ @apply flex h-[90px] items-center justify-between gap-3 px-3.5 py-2.5 outline-none focus-visible:ring-2 sm:px-4;
324
+ --tw-ring-color: color-mix(
325
+ in oklab,
326
+ var(--welcome-accent) 35%,
327
+ transparent
328
+ );
329
+ }
330
+
331
+ .welcome-entry-icon {
332
+ @apply flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-[10px] border transition-colors;
333
+ background-color: var(--welcome-icon-muted-bg);
334
+ border-color: var(--welcome-icon-muted-border);
335
+ color: var(--welcome-subtitle);
336
+ }
337
+
338
+ .welcome-entry-card:hover .welcome-entry-icon,
339
+ .welcome-entry-card:focus-within .welcome-entry-icon {
340
+ background-color: var(--welcome-accent-soft);
341
+ border-color: var(--welcome-icon-active-border);
342
+ color: var(--welcome-accent);
343
+ }
344
+
345
+ .welcome-entry-title {
346
+ @apply truncate text-left text-lg leading-tight font-semibold;
347
+ color: var(--welcome-title);
348
+ }
349
+
350
+ .welcome-entry-description {
351
+ @apply mt-1 truncate text-left text-[13px] leading-tight;
352
+ color: var(--welcome-subtitle);
353
+ }
354
+
355
+ .welcome-entry-arrow {
356
+ @apply h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5;
357
+ color: var(--welcome-title);
358
+ }
359
+
360
+ .welcome-register {
361
+ @apply text-center text-[13px];
362
+ color: var(--welcome-subtitle);
363
+ }
364
+
365
+ .welcome-register-link {
366
+ @apply font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none;
367
+ color: var(--welcome-accent);
368
+ --tw-ring-color: color-mix(
369
+ in oklab,
370
+ var(--welcome-accent) 35%,
371
+ transparent
372
+ );
373
+ }
374
+
375
+ .register-shell {
376
+ @apply relative flex min-h-svh items-center justify-center px-4 py-8 sm:px-6;
377
+ background-color: var(--register-shell-bg);
378
+ }
379
+
380
+ .register-choice-content {
381
+ @apply w-full max-w-[561px];
382
+ }
383
+
384
+ .register-choice-back {
385
+ @apply mb-3;
386
+ }
387
+
388
+ .register-choice-options {
389
+ @apply space-y-3;
390
+ }
391
+
392
+ .register-choice-card[data-highlighted='true'] {
393
+ border-color: var(--welcome-card-active-border);
394
+ box-shadow: var(--welcome-card-active-shadow);
395
+ }
396
+
397
+ .register-choice-card[data-highlighted='true'] .welcome-entry-icon {
398
+ background-color: var(--welcome-accent-soft);
399
+ border-color: var(--welcome-icon-active-border);
400
+ color: var(--welcome-accent);
401
+ }
402
+
403
+ .register-card {
404
+ @apply w-full max-w-[559px] gap-0 rounded-[17px] border py-0;
405
+ background-color: var(--register-card-bg) !important;
406
+ border-color: var(--register-card-border) !important;
407
+ box-shadow: var(--register-card-shadow) !important;
408
+ }
409
+
410
+ .register-card-content {
411
+ @apply px-5 py-6 sm:px-[38px] sm:py-7;
412
+ }
413
+
414
+ .register-back-link {
415
+ @apply inline-flex text-sm font-semibold underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none;
416
+ color: var(--welcome-subtitle);
417
+ --tw-ring-color: color-mix(
418
+ in oklab,
419
+ var(--welcome-accent) 35%,
420
+ transparent
421
+ );
422
+ }
423
+
424
+ .register-heading {
425
+ @apply mt-2 text-center text-2xl leading-tight font-semibold;
426
+ color: var(--welcome-title);
427
+ }
428
+
429
+ .register-form {
430
+ @apply mt-7 flex flex-col gap-4;
431
+ }
432
+
433
+ .register-field {
434
+ @apply grid gap-1.5;
435
+ }
436
+
437
+ .register-label {
438
+ @apply text-base leading-none font-medium;
439
+ color: var(--welcome-title);
440
+ }
441
+
442
+ .register-input {
443
+ @apply h-[43px] rounded-[6px] px-3.5 text-sm shadow-none;
444
+ background-color: var(--register-input-bg) !important;
445
+ border-color: var(--register-input-border) !important;
446
+ color: var(--welcome-title) !important;
447
+ }
448
+
449
+ .register-input::placeholder {
450
+ color: var(--register-placeholder);
451
+ opacity: 1;
452
+ }
453
+
454
+ .register-input:focus-visible {
455
+ border-color: var(--register-card-border) !important;
456
+ --tw-ring-color: color-mix(
457
+ in oklab,
458
+ var(--welcome-accent) 35%,
459
+ transparent
460
+ );
461
+ }
462
+
463
+ .register-submit {
464
+ @apply mt-1 h-[43px] w-full rounded-[6px] border text-base font-semibold shadow-none;
465
+ background-color: var(--welcome-accent) !important;
466
+ border-color: var(--register-button-border) !important;
467
+ color: var(--welcome-accent-foreground) !important;
468
+ }
469
+
470
+ .register-submit:hover {
471
+ background-color: color-mix(
472
+ in oklab,
473
+ var(--welcome-accent) 88%,
474
+ var(--register-card-bg)
475
+ ) !important;
476
+ }
477
+
478
+ .login-card {
479
+ @apply max-w-[561px];
480
+ }
481
+
482
+ .login-card-content {
483
+ @apply px-5 py-7 sm:px-[38px] sm:py-8;
484
+ }
485
+
486
+ .login-heading {
487
+ @apply mt-2;
488
+ }
489
+
490
+ .login-form {
491
+ @apply mt-8 flex flex-col gap-4;
492
+ }
493
+
494
+ .login-remember {
495
+ @apply flex items-center gap-3;
496
+ }
497
+
498
+ .login-remember-label {
499
+ @apply text-sm leading-none font-normal;
500
+ color: var(--welcome-subtitle);
501
+ }
502
+
503
+ .login-submit {
504
+ @apply mt-2;
505
+ }
506
+
507
+ .login-register-text {
508
+ @apply mt-4;
509
+ }
510
+
511
+ .login-status {
512
+ @apply mt-4 text-center text-sm font-medium;
513
+ color: var(--welcome-accent);
514
+ }
515
+
516
+ .student-dashboard-shell {
517
+ @apply min-h-svh;
518
+ background-color: var(--student-bg);
519
+ color: var(--student-text);
520
+ }
521
+
522
+ .student-sidebar {
523
+ border-color: var(--student-border) !important;
524
+ }
525
+
526
+ .student-sidebar [data-sidebar='sidebar'] {
527
+ background-color: var(--student-sidebar-bg);
528
+ border-color: var(--student-border);
529
+ box-shadow: var(--student-sidebar-shadow);
530
+ }
531
+
532
+ .student-sidebar-top {
533
+ @apply border-b;
534
+ border-color: var(--student-border);
535
+ display: flex !important;
536
+ height: 72px !important;
537
+ flex-direction: row !important;
538
+ align-items: center !important;
539
+ justify-content: space-between !important;
540
+ gap: 0 !important;
541
+ padding: 0 20px !important;
542
+ }
543
+
544
+ .student-logo-action {
545
+ @apply flex size-8 shrink-0 items-center justify-center rounded-[8px] transition-transform outline-none focus-visible:ring-2;
546
+ --tw-ring-color: color-mix(
547
+ in oklab,
548
+ var(--student-primary) 45%,
549
+ transparent
550
+ );
551
+ }
552
+
553
+ .student-logo-action:hover {
554
+ @apply scale-[1.03];
555
+ }
556
+
557
+ .student-logo-mark {
558
+ @apply flex size-8 items-center justify-center rounded-[8px];
559
+ background-color: var(--student-primary);
560
+ color: var(--student-primary-foreground);
561
+ }
562
+
563
+ .student-sidebar-trigger {
564
+ @apply rounded-[8px] shadow-none;
565
+ color: var(--student-text);
566
+ width: 32px !important;
567
+ height: 32px !important;
568
+ }
569
+
570
+ .student-sidebar-trigger:hover {
571
+ background-color: var(--student-surface-soft);
572
+ color: var(--student-text);
573
+ }
574
+
575
+ .student-sidebar-content {
576
+ gap: 0 !important;
577
+ }
578
+
579
+ .student-history-group {
580
+ padding: 0 !important;
581
+ }
582
+
583
+ .student-history-label {
584
+ @apply border-b text-xs font-medium;
585
+ border-color: var(--student-border);
586
+ color: var(--student-text);
587
+ display: flex !important;
588
+ height: 44px !important;
589
+ align-items: center !important;
590
+ gap: 6px !important;
591
+ border-radius: 0 !important;
592
+ padding: 0 20px !important;
593
+ margin: 0 !important;
594
+ width: 100% !important;
595
+ background-color: transparent !important;
596
+ cursor: pointer;
597
+ }
598
+
599
+ .student-history-label:hover {
600
+ background-color: var(--student-surface-soft) !important;
601
+ }
602
+
603
+ .student-history-chevron {
604
+ @apply ml-auto size-3 transition-transform;
605
+ }
606
+
607
+ .student-history-label[aria-expanded='false'] .student-history-chevron {
608
+ transform: rotate(-90deg);
609
+ }
610
+
611
+ .student-history-list {
612
+ gap: 4px !important;
613
+ padding: 12px 20px !important;
614
+ }
615
+
616
+ .student-history-menu-button {
617
+ @apply rounded-[8px] text-left shadow-none;
618
+ color: var(--student-muted);
619
+ height: auto !important;
620
+ min-height: 42px !important;
621
+ padding: 8px 4px !important;
622
+ align-items: flex-start !important;
623
+ }
624
+
625
+ .student-history-menu-button:hover,
626
+ .student-history-menu-button[data-active='true'] {
627
+ background-color: var(--student-surface-soft);
628
+ color: var(--student-text);
629
+ }
630
+
631
+ .student-history-menu-icon {
632
+ @apply hidden;
633
+ color: var(--student-text);
634
+ }
635
+
636
+ .student-history-copy,
637
+ .student-user-copy {
638
+ @apply min-w-0;
639
+ }
640
+
641
+ .student-history-title {
642
+ @apply block truncate text-[11px] leading-tight font-medium;
643
+ color: var(--student-text);
644
+ }
645
+
646
+ .student-history-meta {
647
+ @apply mt-1 block truncate text-[9px] leading-tight;
648
+ color: var(--student-muted);
649
+ }
650
+
651
+ .student-sidebar-footer {
652
+ @apply mt-auto border-t;
653
+ border-color: var(--student-border);
654
+ padding: 16px 20px !important;
655
+ }
656
+
657
+ .student-user-menu-button {
658
+ @apply mb-2 w-fit gap-2 rounded-[6px] shadow-none;
659
+ background-color: var(--student-surface-soft);
660
+ color: var(--student-text);
661
+ height: auto !important;
662
+ padding: 6px 8px !important;
663
+ }
664
+
665
+ .student-user-menu-button:hover {
666
+ background-color: var(--student-surface-soft);
667
+ color: var(--student-text);
668
+ }
669
+
670
+ .student-user-avatar {
671
+ @apply size-6 text-[8px];
672
+ background-color: var(--student-surface);
673
+ color: var(--student-text);
674
+ }
675
+
676
+ .student-user-name {
677
+ @apply block truncate text-[11px] leading-tight font-semibold;
678
+ color: var(--student-text);
679
+ }
680
+
681
+ .student-user-role {
682
+ @apply block truncate text-[10px] leading-tight;
683
+ color: var(--student-muted);
684
+ }
685
+
686
+ .student-logout-button {
687
+ @apply justify-start gap-1 rounded-none text-[11px] font-normal shadow-none;
688
+ color: var(--student-danger);
689
+ height: auto !important;
690
+ padding: 0 !important;
691
+ }
692
+
693
+ .student-logout-button:hover {
694
+ background-color: transparent;
695
+ color: var(--student-danger);
696
+ }
697
+
698
+ .group[data-collapsible='icon'] .student-sidebar-top {
699
+ justify-content: center !important;
700
+ padding: 0 !important;
701
+ }
702
+
703
+ .group[data-collapsible='icon'] .student-sidebar-trigger {
704
+ display: none !important;
705
+ }
706
+
707
+ .group[data-collapsible='icon'] .student-history-list {
708
+ padding: 12px 8px !important;
709
+ }
710
+
711
+ .group[data-collapsible='icon'] .student-history-label {
712
+ display: none !important;
713
+ }
714
+
715
+ .group[data-collapsible='icon'] .student-history-menu-button,
716
+ .group[data-collapsible='icon'] .student-user-menu-button,
717
+ .group[data-collapsible='icon'] .student-logout-button {
718
+ @apply mx-auto flex items-center justify-center rounded-[8px];
719
+ background-color: transparent;
720
+ width: 32px !important;
721
+ height: 32px !important;
722
+ min-height: 32px !important;
723
+ padding: 0 !important;
724
+ }
725
+
726
+ .group[data-collapsible='icon'] .student-history-menu-icon {
727
+ @apply block;
728
+ }
729
+
730
+ .group[data-collapsible='icon'] .student-history-copy,
731
+ .group[data-collapsible='icon'] .student-user-copy,
732
+ .group[data-collapsible='icon'] .student-logout-button span {
733
+ @apply hidden;
734
+ }
735
+
736
+ .group[data-collapsible='icon'] .student-sidebar-footer {
737
+ @apply px-2;
738
+ }
739
+
740
+ .student-workspace {
741
+ @apply flex min-w-0 flex-1 flex-col;
742
+ background-color: var(--student-bg);
743
+ color: var(--student-text);
744
+ }
745
+
746
+ .student-topbar {
747
+ @apply flex h-[72px] shrink-0 items-center justify-between border-b px-5 sm:px-6;
748
+ background-color: var(--student-surface);
749
+ border-color: var(--student-border);
750
+ }
751
+
752
+ .student-brand-title {
753
+ @apply text-base font-bold;
754
+ color: var(--student-text);
755
+ }
756
+
757
+ .student-topbar-actions {
758
+ @apply flex items-center gap-3;
759
+ }
760
+
761
+ .student-theme-toggle {
762
+ @apply static size-8 rounded-[8px] shadow-none sm:static;
763
+ background-color: var(--student-surface) !important;
764
+ border-color: var(--student-border-strong) !important;
765
+ color: var(--student-primary) !important;
766
+ }
767
+
768
+ .student-new-session-button {
769
+ @apply h-8 rounded-[8px] px-4 text-xs font-semibold shadow-none;
770
+ background-color: var(--student-primary) !important;
771
+ color: var(--student-primary-foreground) !important;
772
+ }
773
+
774
+ .student-chat-stage {
775
+ @apply flex flex-1 items-center justify-center px-4 py-10;
776
+ }
777
+
778
+ .student-empty-state {
779
+ @apply flex w-full max-w-[610px] -translate-y-8 flex-col items-center;
780
+ }
781
+
782
+ .student-empty-icon {
783
+ @apply mb-5 flex size-[52px] items-center justify-center rounded-[12px];
784
+ background-color: var(--student-primary-soft);
785
+ color: var(--student-primary);
786
+ }
787
+
788
+ .student-greeting {
789
+ @apply mb-9 text-center;
790
+ }
791
+
792
+ .student-greeting h2 {
793
+ @apply text-3xl leading-tight font-bold tracking-[-0.02em];
794
+ color: var(--student-text);
795
+ }
796
+
797
+ .student-greeting p {
798
+ @apply mt-2 text-sm;
799
+ color: var(--student-muted);
800
+ }
801
+
802
+ .student-composer-card {
803
+ @apply w-full gap-0 rounded-[14px] border py-0 shadow-none;
804
+ background-color: var(--student-surface);
805
+ border-color: var(--student-border-strong);
806
+ box-shadow: var(--student-composer-shadow);
807
+ }
808
+
809
+ .student-composer-content {
810
+ @apply relative min-h-[225px] px-5 py-4;
811
+ }
812
+
813
+ .student-composer-form {
814
+ display: contents;
815
+ }
816
+
817
+ .student-status {
818
+ @apply -mt-5 mb-5 text-center text-xs font-medium;
819
+ color: var(--student-primary);
820
+ }
821
+
822
+ .student-course-row {
823
+ @apply flex flex-wrap items-center gap-2 text-[11px] font-medium;
824
+ color: var(--student-text);
825
+ }
826
+
827
+ .student-course-icon,
828
+ .student-question-icon {
829
+ color: var(--student-primary);
830
+ }
831
+
832
+ .student-course-label {
833
+ @apply whitespace-nowrap;
834
+ }
835
+
836
+ .student-course-select {
837
+ @apply h-6 w-[180px] rounded-[5px] px-2 text-[10px] shadow-none;
838
+ background-color: var(--student-surface) !important;
839
+ border-color: var(--student-border-strong) !important;
840
+ color: var(--student-text) !important;
841
+ }
842
+
843
+ .student-course-select svg {
844
+ @apply size-3;
845
+ }
846
+
847
+ .student-composer-divider {
848
+ @apply my-4 h-px w-full;
849
+ background-color: var(--student-border);
850
+ }
851
+
852
+ .student-question-row {
853
+ @apply flex items-start gap-3;
854
+ }
855
+
856
+ .student-question-row textarea {
857
+ @apply min-h-[118px] flex-1 resize-none bg-transparent pt-0 text-[11px] outline-none;
858
+ color: var(--student-text);
859
+ }
860
+
861
+ .student-question-row textarea::placeholder {
862
+ color: var(--student-placeholder);
863
+ }
864
+
865
+ .student-floating-send {
866
+ @apply absolute right-5 bottom-4 size-8 rounded-[8px] shadow-none;
867
+ background-color: var(--student-primary) !important;
868
+ color: var(--student-primary-foreground) !important;
869
+ }
870
+ }
resources/js/app.tsx CHANGED
@@ -7,12 +7,19 @@ import AuthLayout from '@/layouts/auth-layout';
7
  import SettingsLayout from '@/layouts/settings/layout';
8
 
9
  const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
 
 
 
 
 
 
 
10
 
11
  createInertiaApp({
12
  title: (title) => (title ? `${title} - ${appName}` : appName),
13
  layout: (name) => {
14
  switch (true) {
15
- case name === 'welcome':
16
  return null;
17
  case name.startsWith('auth/'):
18
  return AuthLayout;
 
7
  import SettingsLayout from '@/layouts/settings/layout';
8
 
9
  const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
10
+ const fullScreenPages = new Set([
11
+ 'welcome',
12
+ 'auth/login',
13
+ 'auth/register-choice',
14
+ 'auth/register-dosen',
15
+ 'auth/register-mahasiswa',
16
+ ]);
17
 
18
  createInertiaApp({
19
  title: (title) => (title ? `${title} - ${appName}` : appName),
20
  layout: (name) => {
21
  switch (true) {
22
+ case fullScreenPages.has(name):
23
  return null;
24
  case name.startsWith('auth/'):
25
  return AuthLayout;
resources/js/components/appearance-toggle.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Moon, Sun } from 'lucide-react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { useAppearance } from '@/hooks/use-appearance';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ type AppearanceToggleProps = {
7
+ className?: string;
8
+ };
9
+
10
+ export default function AppearanceToggle({ className }: AppearanceToggleProps) {
11
+ const { resolvedAppearance, updateAppearance } = useAppearance();
12
+ const isDarkMode = resolvedAppearance === 'dark';
13
+
14
+ const handleToggle = (): void => {
15
+ updateAppearance(isDarkMode ? 'light' : 'dark');
16
+ };
17
+
18
+ return (
19
+ <Button
20
+ variant="outline"
21
+ size="icon"
22
+ type="button"
23
+ onClick={handleToggle}
24
+ className={cn('welcome-theme-toggle', className)}
25
+ aria-label="Toggle appearance"
26
+ >
27
+ {isDarkMode ? (
28
+ <Sun className="h-4 w-4" />
29
+ ) : (
30
+ <Moon className="h-4 w-4" />
31
+ )}
32
+ </Button>
33
+ );
34
+ }
resources/js/components/auth/register-role-form.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, Link } from '@inertiajs/react';
2
+ import type { FormEvent } from 'react';
3
+ import { useState } from 'react';
4
+ import AppearanceToggle from '@/components/appearance-toggle';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent } from '@/components/ui/card';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { authApi, getAuthErrorMessage } from '@/lib/auth';
10
+ import { register } from '@/routes';
11
+
12
+ const roleContent = {
13
+ mahasiswa: {
14
+ apiRole: 'student',
15
+ heading: 'Registrasi Mahasiswa',
16
+ identifierLabel: 'NIM',
17
+ identifierPlaceholder: 'NIM',
18
+ },
19
+ dosen: {
20
+ apiRole: 'lecturer',
21
+ heading: 'Registrasi Dosen',
22
+ identifierLabel: 'NIDN',
23
+ identifierPlaceholder: 'NIDN',
24
+ },
25
+ } as const;
26
+
27
+ export type RegisterRole = keyof typeof roleContent;
28
+
29
+ type RegisterRoleFormProps = {
30
+ role: RegisterRole;
31
+ };
32
+
33
+ type RegisterFieldProps = {
34
+ autoComplete?: string;
35
+ autoFocus?: boolean;
36
+ label: string;
37
+ name: string;
38
+ placeholder: string;
39
+ required?: boolean;
40
+ tabIndex: number;
41
+ type?: string;
42
+ };
43
+
44
+ function RegisterField({
45
+ autoComplete,
46
+ autoFocus = false,
47
+ label,
48
+ name,
49
+ placeholder,
50
+ required = false,
51
+ tabIndex,
52
+ type = 'text',
53
+ }: RegisterFieldProps) {
54
+ return (
55
+ <div className="register-field">
56
+ <Label htmlFor={name} className="register-label">
57
+ {label}
58
+ </Label>
59
+ <Input
60
+ id={name}
61
+ name={name}
62
+ type={type}
63
+ autoComplete={autoComplete}
64
+ autoFocus={autoFocus}
65
+ placeholder={placeholder}
66
+ required={required}
67
+ tabIndex={tabIndex}
68
+ className="register-input"
69
+ />
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
75
+ const content = roleContent[role];
76
+ const [backendMessage, setBackendMessage] = useState<string>();
77
+ const [isProcessing, setIsProcessing] = useState(false);
78
+
79
+ const handleSubmit = async (
80
+ event: FormEvent<HTMLFormElement>,
81
+ ): Promise<void> => {
82
+ event.preventDefault();
83
+ setBackendMessage(undefined);
84
+ setIsProcessing(true);
85
+
86
+ const formData = new FormData(event.currentTarget);
87
+
88
+ try {
89
+ const response = await authApi.register({
90
+ email: String(formData.get('email') ?? ''),
91
+ password: String(formData.get('password') ?? ''),
92
+ name: String(formData.get('name') ?? ''),
93
+ role: content.apiRole,
94
+ identity_number: String(formData.get('identity_number') ?? ''),
95
+ });
96
+
97
+ setBackendMessage(response.message);
98
+ } catch (error) {
99
+ setBackendMessage(getAuthErrorMessage(error));
100
+ } finally {
101
+ setIsProcessing(false);
102
+ }
103
+ };
104
+
105
+ return (
106
+ <>
107
+ <Head title={content.heading} />
108
+
109
+ <main className="register-shell">
110
+ <AppearanceToggle />
111
+
112
+ <Card className="register-card">
113
+ <CardContent className="register-card-content">
114
+ <Link href={register()} className="register-back-link">
115
+ &larr; Kembali
116
+ </Link>
117
+
118
+ <h1 className="register-heading">{content.heading}</h1>
119
+
120
+ <form className="register-form" onSubmit={handleSubmit}>
121
+ <input type="hidden" name="role" value={role} />
122
+
123
+ <RegisterField
124
+ name="name"
125
+ label="Nama Lengkap"
126
+ placeholder="Masukkan nama lengkap"
127
+ autoComplete="name"
128
+ autoFocus
129
+ required
130
+ tabIndex={1}
131
+ />
132
+ <RegisterField
133
+ name="identity_number"
134
+ label={content.identifierLabel}
135
+ placeholder={content.identifierPlaceholder}
136
+ required
137
+ tabIndex={2}
138
+ />
139
+ <RegisterField
140
+ name="email"
141
+ type="email"
142
+ label="Email"
143
+ placeholder="contoh@kampus.ac.id"
144
+ autoComplete="email"
145
+ required
146
+ tabIndex={3}
147
+ />
148
+ <RegisterField
149
+ name="password"
150
+ type="password"
151
+ label="Password"
152
+ placeholder="Minimal 6 karakter"
153
+ autoComplete="new-password"
154
+ required
155
+ tabIndex={4}
156
+ />
157
+
158
+ <Button
159
+ type="submit"
160
+ className="register-submit"
161
+ tabIndex={5}
162
+ disabled={isProcessing}
163
+ data-test="register-role-button"
164
+ >
165
+ Register
166
+ </Button>
167
+ </form>
168
+
169
+ {backendMessage && (
170
+ <p className="login-status">{backendMessage}</p>
171
+ )}
172
+ </CardContent>
173
+ </Card>
174
+ </main>
175
+ </>
176
+ );
177
+ }
resources/js/layouts/app-layout.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import AppLayoutTemplate from '@/layouts/app/app-sidebar-layout';
2
  import type { BreadcrumbItem } from '@/types';
3
 
4
  export default function AppLayout({
@@ -8,9 +8,5 @@ export default function AppLayout({
8
  breadcrumbs?: BreadcrumbItem[];
9
  children: React.ReactNode;
10
  }) {
11
- return (
12
- <AppLayoutTemplate breadcrumbs={breadcrumbs}>
13
- {children}
14
- </AppLayoutTemplate>
15
- );
16
  }
 
1
+ // import AppLayoutTemplate from '@/layouts/app/app-sidebar-layout';
2
  import type { BreadcrumbItem } from '@/types';
3
 
4
  export default function AppLayout({
 
8
  breadcrumbs?: BreadcrumbItem[];
9
  children: React.ReactNode;
10
  }) {
11
+ return <div>{children}</div>;
 
 
 
 
12
  }
resources/js/lib/api.ts CHANGED
@@ -91,7 +91,11 @@ async function parseResponseBody(response: Response): Promise<unknown> {
91
  return text.length > 0 ? text : null;
92
  }
93
 
94
- function resolveErrorMessage(status: number, data: unknown): string {
 
 
 
 
95
  if (
96
  typeof data === 'object' &&
97
  data !== null &&
@@ -101,15 +105,52 @@ function resolveErrorMessage(status: number, data: unknown): string {
101
  return (data as { message: string }).message;
102
  }
103
 
104
- return `Request failed with status ${status}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
 
107
  export async function apiFetch<TResponse>(
108
  path: string,
109
  options: ApiRequestOptions = {},
110
  ): Promise<TResponse> {
111
- const { body, query, token, headers: initialHeaders, ...requestInit } =
112
- options;
 
 
 
 
 
113
 
114
  const headers = new Headers(initialHeaders);
115
  headers.set('Accept', 'application/json');
@@ -128,7 +169,7 @@ export async function apiFetch<TResponse>(
128
 
129
  if (!response.ok) {
130
  throw new ApiError(
131
- resolveErrorMessage(response.status, data),
132
  response.status,
133
  data,
134
  );
 
91
  return text.length > 0 ? text : null;
92
  }
93
 
94
+ export function getBackendErrorMessage(data: unknown): string | undefined {
95
+ if (typeof data === 'string') {
96
+ return data;
97
+ }
98
+
99
  if (
100
  typeof data === 'object' &&
101
  data !== null &&
 
105
  return (data as { message: string }).message;
106
  }
107
 
108
+ if (
109
+ typeof data === 'object' &&
110
+ data !== null &&
111
+ 'error' in data &&
112
+ typeof (data as { error?: unknown }).error === 'string'
113
+ ) {
114
+ return (data as { error: string }).error;
115
+ }
116
+
117
+ if (typeof data === 'object' && data !== null && 'detail' in data) {
118
+ const detail = (data as { detail?: unknown }).detail;
119
+
120
+ if (typeof detail === 'string') {
121
+ return detail;
122
+ }
123
+
124
+ if (Array.isArray(detail)) {
125
+ const firstError = detail.find(
126
+ (item): item is { msg?: unknown; message?: unknown } =>
127
+ typeof item === 'object' && item !== null,
128
+ );
129
+
130
+ if (typeof firstError?.msg === 'string') {
131
+ return firstError.msg;
132
+ }
133
+
134
+ if (typeof firstError?.message === 'string') {
135
+ return firstError.message;
136
+ }
137
+ }
138
+ }
139
+
140
+ return undefined;
141
  }
142
 
143
  export async function apiFetch<TResponse>(
144
  path: string,
145
  options: ApiRequestOptions = {},
146
  ): Promise<TResponse> {
147
+ const {
148
+ body,
149
+ query,
150
+ token,
151
+ headers: initialHeaders,
152
+ ...requestInit
153
+ } = options;
154
 
155
  const headers = new Headers(initialHeaders);
156
  headers.set('Accept', 'application/json');
 
169
 
170
  if (!response.ok) {
171
  throw new ApiError(
172
+ getBackendErrorMessage(data) ?? '',
173
  response.status,
174
  data,
175
  );
resources/js/lib/auth.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api, ApiError, getBackendErrorMessage } from '@/lib/api';
2
+
3
+ const AUTH_TOKEN_KEY = 'sevima_raghub_auth_token';
4
+ const AUTH_USER_KEY = 'sevima_raghub_auth_user';
5
+
6
+ export type AuthRole = 'admin' | 'student' | 'lecturer';
7
+
8
+ export type AuthUser = {
9
+ id: number;
10
+ identity_number: string;
11
+ name?: string;
12
+ role: AuthRole;
13
+ email: string;
14
+ status: 'active' | 'inactive';
15
+ created_at: string;
16
+ updated_at: string;
17
+ };
18
+
19
+ export type LoginRequest = {
20
+ email: string;
21
+ password: string;
22
+ };
23
+
24
+ export type LoginResponse = {
25
+ token: string;
26
+ user: AuthUser;
27
+ };
28
+
29
+ export type RegisterRequest = {
30
+ email: string;
31
+ password: string;
32
+ name: string;
33
+ role: AuthRole;
34
+ identity_number: string;
35
+ };
36
+
37
+ export type RegisterResponse = {
38
+ message: string;
39
+ user: AuthUser;
40
+ };
41
+
42
+ export const authApi = {
43
+ login(payload: LoginRequest) {
44
+ return api.post<LoginResponse>('/auth/login', payload);
45
+ },
46
+
47
+ register(payload: RegisterRequest) {
48
+ return api.post<RegisterResponse>('/auth/register', payload);
49
+ },
50
+ };
51
+
52
+ function getBrowserStorage(remember: boolean): Storage | null {
53
+ if (typeof window === 'undefined') {
54
+ return null;
55
+ }
56
+
57
+ return remember ? window.localStorage : window.sessionStorage;
58
+ }
59
+
60
+ export function saveAuthSession(
61
+ response: LoginResponse,
62
+ remember: boolean,
63
+ ): void {
64
+ const storage = getBrowserStorage(remember);
65
+ const staleStorage = getBrowserStorage(!remember);
66
+
67
+ if (!storage) {
68
+ return;
69
+ }
70
+
71
+ storage.setItem(AUTH_TOKEN_KEY, response.token);
72
+ storage.setItem(AUTH_USER_KEY, JSON.stringify(response.user));
73
+
74
+ staleStorage?.removeItem(AUTH_TOKEN_KEY);
75
+ staleStorage?.removeItem(AUTH_USER_KEY);
76
+ }
77
+
78
+ export function getStoredAuthToken(): string | null {
79
+ if (typeof window === 'undefined') {
80
+ return null;
81
+ }
82
+
83
+ return (
84
+ window.localStorage.getItem(AUTH_TOKEN_KEY) ??
85
+ window.sessionStorage.getItem(AUTH_TOKEN_KEY)
86
+ );
87
+ }
88
+
89
+ export function getStoredAuthUser(): AuthUser | null {
90
+ if (typeof window === 'undefined') {
91
+ return null;
92
+ }
93
+
94
+ const rawUser =
95
+ window.localStorage.getItem(AUTH_USER_KEY) ??
96
+ window.sessionStorage.getItem(AUTH_USER_KEY);
97
+
98
+ if (!rawUser) {
99
+ return null;
100
+ }
101
+
102
+ try {
103
+ return JSON.parse(rawUser) as AuthUser;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export function clearAuthSession(): void {
110
+ if (typeof window === 'undefined') {
111
+ return;
112
+ }
113
+
114
+ window.localStorage.removeItem(AUTH_TOKEN_KEY);
115
+ window.localStorage.removeItem(AUTH_USER_KEY);
116
+ window.sessionStorage.removeItem(AUTH_TOKEN_KEY);
117
+ window.sessionStorage.removeItem(AUTH_USER_KEY);
118
+ }
119
+
120
+ export function getAuthErrorMessage(error: unknown): string | undefined {
121
+ if (error instanceof ApiError) {
122
+ return getBackendErrorMessage(error.data);
123
+ }
124
+
125
+ return undefined;
126
+ }
resources/js/lib/rag.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api, ApiError, getBackendErrorMessage } from '@/lib/api';
2
+
3
+ export type CourseResponse = {
4
+ id: number;
5
+ title: string;
6
+ description?: string;
7
+ };
8
+
9
+ export type CourseListResponse = {
10
+ data: CourseResponse[];
11
+ total: number;
12
+ page: number;
13
+ limit: number;
14
+ };
15
+
16
+ export type ChatSessionResponse = {
17
+ uuid_id: string;
18
+ user_id: string;
19
+ user_name?: string;
20
+ course_id: string;
21
+ course_name?: string;
22
+ title: string;
23
+ message_count: number;
24
+ last_message_at?: string;
25
+ created_at: string;
26
+ updated_at: string;
27
+ };
28
+
29
+ export type ChatSessionListResponse = {
30
+ data: ChatSessionResponse[];
31
+ pagination: unknown;
32
+ };
33
+
34
+ export type CreateChatSessionRequest = {
35
+ course_id: string;
36
+ title: string;
37
+ };
38
+
39
+ export type ChatRestRequest = {
40
+ content: string;
41
+ };
42
+
43
+ export type ChatMessageResponse = {
44
+ uuid_id: string;
45
+ role: string;
46
+ content: string;
47
+ sources: {
48
+ document_id: string;
49
+ filename: string;
50
+ excerpt: string;
51
+ }[];
52
+ created_at: string;
53
+ };
54
+
55
+ type ListCoursesOptions = {
56
+ page?: number;
57
+ limit?: number;
58
+ signal?: AbortSignal;
59
+ };
60
+
61
+ type AuthenticatedListOptions = ListCoursesOptions & {
62
+ token?: string;
63
+ courseId?: string;
64
+ };
65
+
66
+ export const ragApi = {
67
+ listCourses({ page = 1, limit = 100, signal }: ListCoursesOptions = {}) {
68
+ return api.get<CourseListResponse>('/courses', {
69
+ query: { page, limit },
70
+ signal,
71
+ });
72
+ },
73
+
74
+ listChatSessions({
75
+ page = 1,
76
+ limit = 20,
77
+ token,
78
+ courseId,
79
+ signal,
80
+ }: AuthenticatedListOptions = {}) {
81
+ return api.get<ChatSessionListResponse>('/chats/sessions', {
82
+ query: { page, limit, course_id: courseId },
83
+ signal,
84
+ token,
85
+ });
86
+ },
87
+
88
+ createChatSession(payload: CreateChatSessionRequest, token?: string) {
89
+ return api.post<ChatSessionResponse>('/chats/sessions', payload, {
90
+ token,
91
+ });
92
+ },
93
+
94
+ sendMessage(sessionId: string, payload: ChatRestRequest, token?: string) {
95
+ return api.post<ChatMessageResponse>(
96
+ `/chats/sessions/${sessionId}/messages`,
97
+ payload,
98
+ { token },
99
+ );
100
+ },
101
+ };
102
+
103
+ export function getRagErrorMessage(error: unknown): string | undefined {
104
+ if (error instanceof ApiError) {
105
+ return getBackendErrorMessage(error.data);
106
+ }
107
+
108
+ return undefined;
109
+ }
resources/js/pages/auth/login.tsx CHANGED
@@ -1,121 +1,157 @@
1
- import { Form, Head } from '@inertiajs/react';
2
- import InputError from '@/components/input-error';
3
- import PasswordInput from '@/components/password-input';
4
- import TextLink from '@/components/text-link';
5
  import { Button } from '@/components/ui/button';
 
6
  import { Checkbox } from '@/components/ui/checkbox';
7
  import { Input } from '@/components/ui/input';
8
  import { Label } from '@/components/ui/label';
9
- import { Spinner } from '@/components/ui/spinner';
10
- import { register } from '@/routes';
11
- import { store } from '@/routes/login';
12
- import { request } from '@/routes/password';
13
 
14
- type Props = {
 
15
  status?: string;
16
- canResetPassword: boolean;
17
- canRegister: boolean;
18
  };
19
 
20
- export default function Login({
21
- status,
22
- canResetPassword,
23
- canRegister,
24
- }: Props) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  return (
26
  <>
27
- <Head title="Log in" />
28
-
29
- <Form
30
- {...store.form()}
31
- resetOnSuccess={['password']}
32
- className="flex flex-col gap-6"
33
- >
34
- {({ processing, errors }) => (
35
- <>
36
- <div className="grid gap-6">
37
- <div className="grid gap-2">
38
- <Label htmlFor="email">Email address</Label>
 
 
 
 
 
 
 
 
 
 
 
39
  <Input
40
  id="email"
41
- type="email"
42
  name="email"
43
- required
 
44
  autoFocus
 
45
  tabIndex={1}
46
- autoComplete="email"
47
- placeholder="email@example.com"
48
  />
49
- <InputError message={errors.email} />
50
  </div>
51
 
52
- <div className="grid gap-2">
53
- <div className="flex items-center">
54
- <Label htmlFor="password">Password</Label>
55
- {canResetPassword && (
56
- <TextLink
57
- href={request()}
58
- className="ml-auto text-sm"
59
- tabIndex={5}
60
- >
61
- Forgot password?
62
- </TextLink>
63
- )}
64
- </div>
65
- <PasswordInput
66
  id="password"
67
  name="password"
68
- required
69
- tabIndex={2}
70
  autoComplete="current-password"
71
- placeholder="Password"
 
 
72
  />
73
- <InputError message={errors.password} />
74
  </div>
75
 
76
- <div className="flex items-center space-x-3">
77
  <Checkbox
78
  id="remember"
79
  name="remember"
 
 
 
 
80
  tabIndex={3}
81
  />
82
- <Label htmlFor="remember">Remember me</Label>
 
 
 
 
 
83
  </div>
84
 
85
  <Button
86
  type="submit"
87
- className="mt-4 w-full"
88
  tabIndex={4}
89
- disabled={processing}
90
  data-test="login-button"
91
  >
92
- {processing && <Spinner />}
93
- Log in
94
  </Button>
95
- </div>
 
 
 
 
 
 
96
 
97
  {canRegister && (
98
- <div className="text-center text-sm text-muted-foreground">
99
- Don't have an account?{' '}
100
- <TextLink href={register()} tabIndex={5}>
101
- Sign up
102
- </TextLink>
103
- </div>
 
 
 
 
104
  )}
105
- </>
106
- )}
107
- </Form>
108
-
109
- {status && (
110
- <div className="mb-4 text-center text-sm font-medium text-green-600">
111
- {status}
112
- </div>
113
- )}
114
  </>
115
  );
116
  }
117
-
118
- Login.layout = {
119
- title: 'Log in to your account',
120
- description: 'Enter your email and password below to log in',
121
- };
 
1
+ import { Head, Link, router } from '@inertiajs/react';
2
+ import type { FormEvent } from 'react';
3
+ import { useState } from 'react';
4
+ import AppearanceToggle from '@/components/appearance-toggle';
5
  import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent } from '@/components/ui/card';
7
  import { Checkbox } from '@/components/ui/checkbox';
8
  import { Input } from '@/components/ui/input';
9
  import { Label } from '@/components/ui/label';
10
+ import { authApi, getAuthErrorMessage, saveAuthSession } from '@/lib/auth';
11
+ import { dashboard, home, mahasiswa, register } from '@/routes';
 
 
12
 
13
+ type LoginProps = {
14
+ canRegister?: boolean;
15
  status?: string;
 
 
16
  };
17
 
18
+ export default function Login({ canRegister = true, status }: LoginProps) {
19
+ const [backendMessage, setBackendMessage] = useState<string>();
20
+ const [isProcessing, setIsProcessing] = useState(false);
21
+ const [remember, setRemember] = useState(true);
22
+
23
+ const handleSubmit = async (
24
+ event: FormEvent<HTMLFormElement>,
25
+ ): Promise<void> => {
26
+ event.preventDefault();
27
+ setBackendMessage(undefined);
28
+ setIsProcessing(true);
29
+
30
+ const formData = new FormData(event.currentTarget);
31
+
32
+ try {
33
+ const response = await authApi.login({
34
+ email: String(formData.get('email') ?? ''),
35
+ password: String(formData.get('password') ?? ''),
36
+ });
37
+
38
+ saveAuthSession(response, remember);
39
+
40
+ router.visit(
41
+ response.user.role === 'student' ? mahasiswa() : dashboard(),
42
+ );
43
+ } catch (error) {
44
+ setBackendMessage(getAuthErrorMessage(error));
45
+ } finally {
46
+ setIsProcessing(false);
47
+ }
48
+ };
49
+
50
  return (
51
  <>
52
+ <Head title="Login" />
53
+
54
+ <main className="register-shell">
55
+ <AppearanceToggle />
56
+
57
+ <Card className="register-card login-card">
58
+ <CardContent className="login-card-content">
59
+ <Link href={home()} className="register-back-link">
60
+ &larr; Kembali
61
+ </Link>
62
+
63
+ <h1 className="register-heading login-heading">
64
+ Login
65
+ </h1>
66
+
67
+ <form className="login-form" onSubmit={handleSubmit}>
68
+ <div className="register-field">
69
+ <Label
70
+ htmlFor="email"
71
+ className="register-label"
72
+ >
73
+ Email
74
+ </Label>
75
  <Input
76
  id="email"
 
77
  name="email"
78
+ type="email"
79
+ autoComplete="email"
80
  autoFocus
81
+ placeholder="Masukkan email disini"
82
  tabIndex={1}
83
+ className="register-input"
 
84
  />
 
85
  </div>
86
 
87
+ <div className="register-field">
88
+ <Label
89
+ htmlFor="password"
90
+ className="register-label"
91
+ >
92
+ Password
93
+ </Label>
94
+ <Input
 
 
 
 
 
 
95
  id="password"
96
  name="password"
97
+ type="password"
 
98
  autoComplete="current-password"
99
+ placeholder="********"
100
+ tabIndex={2}
101
+ className="register-input"
102
  />
 
103
  </div>
104
 
105
+ <div className="login-remember">
106
  <Checkbox
107
  id="remember"
108
  name="remember"
109
+ checked={remember}
110
+ onCheckedChange={(checked) => {
111
+ setRemember(checked === true);
112
+ }}
113
  tabIndex={3}
114
  />
115
+ <Label
116
+ htmlFor="remember"
117
+ className="login-remember-label"
118
+ >
119
+ Remember me
120
+ </Label>
121
  </div>
122
 
123
  <Button
124
  type="submit"
125
+ className="register-submit login-submit"
126
  tabIndex={4}
127
+ disabled={isProcessing}
128
  data-test="login-button"
129
  >
130
+ Sign In
 
131
  </Button>
132
+ </form>
133
+
134
+ {(backendMessage ?? status) && (
135
+ <p className="login-status">
136
+ {backendMessage ?? status}
137
+ </p>
138
+ )}
139
 
140
  {canRegister && (
141
+ <p className="welcome-register login-register-text">
142
+ Belum punya akun?{' '}
143
+ <Link
144
+ href={register()}
145
+ className="welcome-register-link"
146
+ tabIndex={5}
147
+ >
148
+ Register
149
+ </Link>
150
+ </p>
151
  )}
152
+ </CardContent>
153
+ </Card>
154
+ </main>
 
 
 
 
 
 
155
  </>
156
  );
157
  }
 
 
 
 
 
resources/js/pages/auth/register-choice.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, Link } from '@inertiajs/react';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import { BookOpen, ChevronRight, MessageSquare } from 'lucide-react';
4
+ import type { ComponentProps } from 'react';
5
+ import AppearanceToggle from '@/components/appearance-toggle';
6
+ import { Card, CardContent } from '@/components/ui/card';
7
+ import { home } from '@/routes';
8
+ import { dosen, mahasiswa } from '@/routes/register';
9
+
10
+ type RegisterOptionCardProps = {
11
+ description: string;
12
+ href: NonNullable<ComponentProps<typeof Link>['href']>;
13
+ icon: LucideIcon;
14
+ isHighlighted?: boolean;
15
+ title: string;
16
+ };
17
+
18
+ function RegisterOptionCard({
19
+ description,
20
+ href,
21
+ icon: Icon,
22
+ isHighlighted = false,
23
+ title,
24
+ }: RegisterOptionCardProps) {
25
+ return (
26
+ <Card className="welcome-entry-card register-choice-card px-1 py-0">
27
+ <CardContent className="p-0">
28
+ <Link href={href} className="group welcome-entry-link">
29
+ <div className="flex min-w-0 items-start gap-3">
30
+ <div className="welcome-entry-icon">
31
+ <Icon className="h-5 w-5" />
32
+ </div>
33
+ <div className="min-w-0">
34
+ <p className="welcome-entry-title">{title}</p>
35
+ <p className="welcome-entry-description">
36
+ {description}
37
+ </p>
38
+ </div>
39
+ </div>
40
+ <ChevronRight className="welcome-entry-arrow" />
41
+ </Link>
42
+ </CardContent>
43
+ </Card>
44
+ );
45
+ }
46
+
47
+ export default function RegisterChoice() {
48
+ return (
49
+ <>
50
+ <Head title="Register" />
51
+
52
+ <main className="register-shell">
53
+ <AppearanceToggle />
54
+
55
+ <section className="register-choice-content">
56
+ <Link
57
+ href={home()}
58
+ className="register-back-link register-choice-back"
59
+ >
60
+ &larr; Kembali
61
+ </Link>
62
+
63
+ <div className="register-choice-options">
64
+ <RegisterOptionCard
65
+ href={dosen()}
66
+ icon={BookOpen}
67
+ title="Register sebagai Dosen"
68
+ description="Kelola knowledge base dan dokumen"
69
+ />
70
+ <RegisterOptionCard
71
+ href={mahasiswa()}
72
+ icon={MessageSquare}
73
+ title="Register sebagai Mahasiswa"
74
+ description="Tanya jawab materi kuliah dengan AI"
75
+ isHighlighted
76
+ />
77
+ </div>
78
+ </section>
79
+ </main>
80
+ </>
81
+ );
82
+ }
resources/js/pages/auth/register-dosen.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import RegisterRoleForm from '@/components/auth/register-role-form';
2
+
3
+ export default function RegisterDosen() {
4
+ return <RegisterRoleForm role="dosen" />;
5
+ }
resources/js/pages/auth/register-mahasiswa.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import RegisterRoleForm from '@/components/auth/register-role-form';
2
+
3
+ export default function RegisterMahasiswa() {
4
+ return <RegisterRoleForm role="mahasiswa" />;
5
+ }
resources/js/pages/mahasiswa.tsx ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, router } from '@inertiajs/react';
2
+ import {
3
+ BookOpen,
4
+ ChevronDown,
5
+ Clock3,
6
+ LogOut,
7
+ MessageSquare,
8
+ Send,
9
+ Sparkles,
10
+ } from 'lucide-react';
11
+ import type { CSSProperties, FormEvent } from 'react';
12
+ import { useEffect, useState } from 'react';
13
+
14
+ import AppearanceToggle from '@/components/appearance-toggle';
15
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
16
+ import { Button } from '@/components/ui/button';
17
+ import { Card, CardContent } from '@/components/ui/card';
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from '@/components/ui/select';
25
+ import {
26
+ Sidebar,
27
+ SidebarContent,
28
+ SidebarFooter,
29
+ SidebarGroup,
30
+ SidebarGroupContent,
31
+ SidebarGroupLabel,
32
+ SidebarHeader,
33
+ SidebarInset,
34
+ SidebarMenu,
35
+ SidebarMenuButton,
36
+ SidebarMenuItem,
37
+ SidebarProvider,
38
+ SidebarRail,
39
+ SidebarTrigger,
40
+ useSidebar,
41
+ } from '@/components/ui/sidebar';
42
+ import {
43
+ clearAuthSession,
44
+ getStoredAuthToken,
45
+ getStoredAuthUser,
46
+ } from '@/lib/auth';
47
+ import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
48
+ import { getRagErrorMessage, ragApi } from '@/lib/rag';
49
+ import { login } from '@/routes';
50
+
51
+ const sidebarStyle = {
52
+ '--sidebar-width': '212px',
53
+ '--sidebar-width-icon': '64px',
54
+ } as CSSProperties;
55
+
56
+ function getStoredStudentName(): string {
57
+ return getStoredAuthUser()?.name ?? 'Mahasiswa';
58
+ }
59
+
60
+ function getInitials(name: string): string {
61
+ return name
62
+ .split(' ')
63
+ .filter(Boolean)
64
+ .slice(0, 2)
65
+ .map((part) => part[0]?.toUpperCase())
66
+ .join('');
67
+ }
68
+
69
+ function handleLogout(): void {
70
+ clearAuthSession();
71
+ router.visit(login());
72
+ }
73
+
74
+ function isAbortError(error: unknown): boolean {
75
+ return error instanceof DOMException && error.name === 'AbortError';
76
+ }
77
+
78
+ function formatSessionTime(value?: string): string {
79
+ if (!value) {
80
+ return '';
81
+ }
82
+
83
+ const date = new Date(value);
84
+
85
+ if (Number.isNaN(date.getTime())) {
86
+ return value;
87
+ }
88
+
89
+ return new Intl.DateTimeFormat('en-US', {
90
+ day: 'numeric',
91
+ hour: 'numeric',
92
+ minute: '2-digit',
93
+ month: 'short',
94
+ }).format(date);
95
+ }
96
+
97
+ function StudentSidebarLogo() {
98
+ const { state, toggleSidebar } = useSidebar();
99
+
100
+ const handleLogoClick = (): void => {
101
+ if (state === 'collapsed') {
102
+ toggleSidebar();
103
+ }
104
+ };
105
+
106
+ return (
107
+ <button
108
+ aria-label="Buka sidebar"
109
+ className="student-logo-action"
110
+ onClick={handleLogoClick}
111
+ type="button"
112
+ >
113
+ <span className="student-logo-mark">
114
+ <MessageSquare className="size-5" />
115
+ </span>
116
+ </button>
117
+ );
118
+ }
119
+
120
+ function StudentSidebar({
121
+ chatSessions,
122
+ isLoading,
123
+ studentName,
124
+ }: {
125
+ chatSessions: ChatSessionResponse[];
126
+ isLoading: boolean;
127
+ studentName: string;
128
+ }) {
129
+ const [isHistoryOpen, setIsHistoryOpen] = useState(true);
130
+ const { state } = useSidebar();
131
+ const isSidebarCollapsed = state === 'collapsed';
132
+
133
+ return (
134
+ <Sidebar className="student-sidebar" collapsible="icon">
135
+ <SidebarHeader className="student-sidebar-top">
136
+ <StudentSidebarLogo />
137
+ <SidebarTrigger className="student-sidebar-trigger" />
138
+ </SidebarHeader>
139
+
140
+ <SidebarContent className="student-sidebar-content">
141
+ <SidebarGroup className="student-history-group">
142
+ {isSidebarCollapsed ? (
143
+ <SidebarGroupContent>
144
+ <SidebarMenu className="student-history-list">
145
+ <SidebarMenuItem>
146
+ <SidebarMenuButton
147
+ className="student-history-menu-button"
148
+ tooltip="Riwayat Chat"
149
+ >
150
+ <Clock3 className="student-history-menu-icon size-4" />
151
+ <span className="student-history-copy">
152
+ <span className="student-history-title">
153
+ Riwayat Chat
154
+ </span>
155
+ </span>
156
+ </SidebarMenuButton>
157
+ </SidebarMenuItem>
158
+ </SidebarMenu>
159
+ </SidebarGroupContent>
160
+ ) : (
161
+ <>
162
+ <SidebarGroupLabel asChild>
163
+ <button
164
+ aria-expanded={isHistoryOpen}
165
+ className="student-history-label"
166
+ onClick={() =>
167
+ setIsHistoryOpen(
168
+ (currentState) => !currentState,
169
+ )
170
+ }
171
+ type="button"
172
+ >
173
+ <Clock3 className="size-4" />
174
+ <span>Riwayat Chat</span>
175
+ <ChevronDown className="student-history-chevron" />
176
+ </button>
177
+ </SidebarGroupLabel>
178
+
179
+ {isHistoryOpen && (
180
+ <SidebarGroupContent>
181
+ <SidebarMenu className="student-history-list">
182
+ {isLoading && (
183
+ <SidebarMenuItem>
184
+ <SidebarMenuButton
185
+ className="student-history-menu-button"
186
+ disabled
187
+ >
188
+ <Clock3 className="student-history-menu-icon size-4" />
189
+ <span className="student-history-copy">
190
+ <span className="student-history-title">
191
+ Memuat riwayat
192
+ </span>
193
+ </span>
194
+ </SidebarMenuButton>
195
+ </SidebarMenuItem>
196
+ )}
197
+
198
+ {chatSessions.map((session) => (
199
+ <SidebarMenuItem
200
+ key={session.uuid_id}
201
+ >
202
+ <SidebarMenuButton
203
+ className="student-history-menu-button"
204
+ tooltip={session.title}
205
+ >
206
+ <Clock3 className="student-history-menu-icon size-4" />
207
+ <span className="student-history-copy">
208
+ <span className="student-history-title">
209
+ {session.title}
210
+ </span>
211
+ {formatSessionTime(
212
+ session.last_message_at ??
213
+ session.updated_at ??
214
+ session.created_at,
215
+ ) && (
216
+ <span className="student-history-meta">
217
+ {formatSessionTime(
218
+ session.last_message_at ??
219
+ session.updated_at ??
220
+ session.created_at,
221
+ )}
222
+ </span>
223
+ )}
224
+ </span>
225
+ </SidebarMenuButton>
226
+ </SidebarMenuItem>
227
+ ))}
228
+ </SidebarMenu>
229
+ </SidebarGroupContent>
230
+ )}
231
+ </>
232
+ )}
233
+ </SidebarGroup>
234
+ </SidebarContent>
235
+
236
+ <SidebarFooter className="student-sidebar-footer">
237
+ <SidebarMenu>
238
+ <SidebarMenuItem>
239
+ <SidebarMenuButton
240
+ className="student-user-menu-button"
241
+ tooltip={studentName}
242
+ >
243
+ <Avatar className="student-user-avatar">
244
+ <AvatarFallback>
245
+ {getInitials(studentName) || 'AK'}
246
+ </AvatarFallback>
247
+ </Avatar>
248
+ <span className="student-user-copy">
249
+ <span className="student-user-name">
250
+ {studentName}
251
+ </span>
252
+ <span className="student-user-role">
253
+ Mahasiswa
254
+ </span>
255
+ </span>
256
+ </SidebarMenuButton>
257
+ </SidebarMenuItem>
258
+
259
+ <SidebarMenuItem>
260
+ <SidebarMenuButton
261
+ className="student-logout-button"
262
+ onClick={handleLogout}
263
+ tooltip="Logout"
264
+ >
265
+ <LogOut className="size-4" />
266
+ <span>Logout</span>
267
+ </SidebarMenuButton>
268
+ </SidebarMenuItem>
269
+ </SidebarMenu>
270
+ </SidebarFooter>
271
+
272
+ <SidebarRail />
273
+ </Sidebar>
274
+ );
275
+ }
276
+
277
+ function QuestionComposer({
278
+ courses,
279
+ isLoadingCourses,
280
+ isProcessing,
281
+ onCourseChange,
282
+ onQuestionChange,
283
+ onSubmit,
284
+ question,
285
+ selectedCourseId,
286
+ }: {
287
+ courses: CourseResponse[];
288
+ isLoadingCourses: boolean;
289
+ isProcessing: boolean;
290
+ onCourseChange: (courseId: string) => void;
291
+ onQuestionChange: (question: string) => void;
292
+ onSubmit: (event: FormEvent<HTMLFormElement>) => void;
293
+ question: string;
294
+ selectedCourseId: string;
295
+ }) {
296
+ return (
297
+ <Card className="student-composer-card py-0">
298
+ <CardContent className="student-composer-content">
299
+ <form className="student-composer-form" onSubmit={onSubmit}>
300
+ <div className="student-course-row">
301
+ <BookOpen className="student-course-icon size-5" />
302
+ <span className="student-course-label text-sm">
303
+ Mata Kuliah:
304
+ </span>
305
+
306
+ <Select
307
+ disabled={isLoadingCourses || courses.length === 0}
308
+ onValueChange={onCourseChange}
309
+ value={selectedCourseId}
310
+ >
311
+ <SelectTrigger
312
+ className="student-course-select"
313
+ size="sm"
314
+ >
315
+ <SelectValue placeholder="Pilih mata kuliah" />
316
+ </SelectTrigger>
317
+ <SelectContent>
318
+ {courses.map((course) => (
319
+ <SelectItem
320
+ key={course.id}
321
+ value={String(course.id)}
322
+ >
323
+ {course.title}
324
+ </SelectItem>
325
+ ))}
326
+ </SelectContent>
327
+ </Select>
328
+ </div>
329
+
330
+ <div className="student-composer-divider" />
331
+
332
+ <div className="student-question-row">
333
+ <Sparkles className="student-question-icon size-5" />
334
+ <textarea
335
+ className="text-base"
336
+ aria-label="Pertanyaan"
337
+ onChange={(event) =>
338
+ onQuestionChange(event.target.value)
339
+ }
340
+ placeholder="Tulis pertanyaanmu disini..."
341
+ value={question}
342
+ />
343
+ </div>
344
+
345
+ <Button
346
+ className="student-floating-send"
347
+ disabled={
348
+ isProcessing ||
349
+ selectedCourseId.length === 0 ||
350
+ question.trim().length === 0
351
+ }
352
+ size="icon"
353
+ type="submit"
354
+ >
355
+ <Send className="size-5" />
356
+ </Button>
357
+ </form>
358
+ </CardContent>
359
+ </Card>
360
+ );
361
+ }
362
+
363
+ export default function Mahasiswa() {
364
+ const [backendMessage, setBackendMessage] = useState<string>();
365
+ const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
366
+ const [courses, setCourses] = useState<CourseResponse[]>([]);
367
+ const [isLoading, setIsLoading] = useState(true);
368
+ const [isProcessing, setIsProcessing] = useState(false);
369
+ const [question, setQuestion] = useState('');
370
+ const [selectedCourseId, setSelectedCourseId] = useState('');
371
+ const [studentName] = useState(getStoredStudentName);
372
+
373
+ useEffect(() => {
374
+ const controller = new AbortController();
375
+
376
+ async function loadDashboardData() {
377
+ const token = getStoredAuthToken() ?? undefined;
378
+ const [coursesResult, sessionsResult] = await Promise.allSettled([
379
+ ragApi.listCourses({ signal: controller.signal }),
380
+ ragApi.listChatSessions({ signal: controller.signal, token }),
381
+ ]);
382
+
383
+ if (controller.signal.aborted) {
384
+ return;
385
+ }
386
+
387
+ if (coursesResult.status === 'fulfilled') {
388
+ const nextCourses = coursesResult.value.data;
389
+
390
+ setCourses(nextCourses);
391
+ setSelectedCourseId((currentCourseId) => {
392
+ if (currentCourseId || nextCourses.length === 0) {
393
+ return currentCourseId;
394
+ }
395
+
396
+ return String(nextCourses[0].id);
397
+ });
398
+ } else if (!isAbortError(coursesResult.reason)) {
399
+ setBackendMessage(getRagErrorMessage(coursesResult.reason));
400
+ }
401
+
402
+ if (sessionsResult.status === 'fulfilled') {
403
+ setChatSessions(sessionsResult.value.data);
404
+ } else if (!isAbortError(sessionsResult.reason)) {
405
+ setBackendMessage(getRagErrorMessage(sessionsResult.reason));
406
+ }
407
+
408
+ setIsLoading(false);
409
+ }
410
+
411
+ void loadDashboardData();
412
+
413
+ return () => {
414
+ controller.abort();
415
+ };
416
+ }, []);
417
+
418
+ const handleQuestionSubmit = async (
419
+ event: FormEvent<HTMLFormElement>,
420
+ ): Promise<void> => {
421
+ event.preventDefault();
422
+
423
+ const trimmedQuestion = question.trim();
424
+
425
+ if (!selectedCourseId || !trimmedQuestion) {
426
+ return;
427
+ }
428
+
429
+ setIsProcessing(true);
430
+ setBackendMessage(undefined);
431
+
432
+ try {
433
+ const token = getStoredAuthToken() ?? undefined;
434
+ const session = await ragApi.createChatSession(
435
+ {
436
+ course_id: selectedCourseId,
437
+ title: trimmedQuestion,
438
+ },
439
+ token,
440
+ );
441
+
442
+ await ragApi.sendMessage(
443
+ session.uuid_id,
444
+ { content: trimmedQuestion },
445
+ token,
446
+ );
447
+
448
+ setChatSessions((currentSessions) => [session, ...currentSessions]);
449
+ setQuestion('');
450
+ } catch (error) {
451
+ setBackendMessage(getRagErrorMessage(error));
452
+ } finally {
453
+ setIsProcessing(false);
454
+ }
455
+ };
456
+
457
+ return (
458
+ <>
459
+ <Head title="RAG Hub" />
460
+
461
+ <SidebarProvider
462
+ className="student-dashboard-shell"
463
+ defaultOpen
464
+ style={sidebarStyle}
465
+ >
466
+ <StudentSidebar
467
+ chatSessions={chatSessions}
468
+ isLoading={isLoading}
469
+ studentName={studentName}
470
+ />
471
+
472
+ <SidebarInset className="student-workspace">
473
+ <header className="student-topbar">
474
+ <h1 className="student-brand-title">RAG Hub</h1>
475
+
476
+ <div className="student-topbar-actions">
477
+ <AppearanceToggle className="student-theme-toggle" />
478
+ <Button
479
+ className="student-new-session-button"
480
+ type="button"
481
+ >
482
+ + Sesi Baru
483
+ </Button>
484
+ </div>
485
+ </header>
486
+
487
+ <div className="student-chat-stage">
488
+ <section className="student-empty-state">
489
+ <div className="student-empty-icon">
490
+ <MessageSquare className="size-8" />
491
+ </div>
492
+
493
+ <div className="student-greeting">
494
+ <h2>Good Morning, {studentName}</h2>
495
+ <p>
496
+ Pilih mata kuliah, lalu kirim pertanyaan
497
+ pertama untuk membuat sesi baru
498
+ </p>
499
+ </div>
500
+
501
+ {backendMessage && (
502
+ <p className="student-status">
503
+ {backendMessage}
504
+ </p>
505
+ )}
506
+
507
+ <QuestionComposer
508
+ courses={courses}
509
+ isLoadingCourses={isLoading}
510
+ isProcessing={isProcessing}
511
+ onCourseChange={setSelectedCourseId}
512
+ onQuestionChange={setQuestion}
513
+ onSubmit={handleQuestionSubmit}
514
+ question={question}
515
+ selectedCourseId={selectedCourseId}
516
+ />
517
+ </section>
518
+ </div>
519
+ </SidebarInset>
520
+ </SidebarProvider>
521
+ </>
522
+ );
523
+ }
resources/js/pages/welcome.tsx CHANGED
@@ -1,21 +1,99 @@
1
- import { Head } from '@inertiajs/react';
 
 
 
 
 
 
2
 
3
- const Welcome = () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  return (
5
  <>
6
  <Head title="Welcome" />
7
- <div>
8
- <h1>SEVIMA RAG HUB</h1>
9
- <p>ini adalah SEVIMA RAG HUB</p>
10
- <div>
11
- <p>Masuk sebagai Dosen</p>
12
- </div>
13
- <div>
14
- <p>Masuk sebagai Mahasiswa</p>
15
- </div>
16
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  </>
18
  );
19
- };
20
-
21
- export default Welcome;
 
1
+ import { Head, Link } from '@inertiajs/react';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import { BookOpen, ChevronRight, MessageSquare } from 'lucide-react';
4
+ import type { ComponentProps } from 'react';
5
+ import AppearanceToggle from '@/components/appearance-toggle';
6
+ import { Card, CardContent } from '@/components/ui/card';
7
+ import { login, mahasiswa, register } from '@/routes';
8
 
9
+ type WelcomeProps = {
10
+ canRegister?: boolean;
11
+ };
12
+
13
+ type AuthEntryCardProps = {
14
+ description: string;
15
+ href: NonNullable<ComponentProps<typeof Link>['href']>;
16
+ icon: LucideIcon;
17
+ title: string;
18
+ };
19
+
20
+ function AuthEntryCard({
21
+ description,
22
+ href,
23
+ icon: Icon,
24
+ title,
25
+ }: AuthEntryCardProps) {
26
+ return (
27
+ <Card className="welcome-entry-card px-1 py-0">
28
+ <CardContent className="p-0">
29
+ <Link href={href} className="group welcome-entry-link">
30
+ <div className="flex min-w-0 items-start gap-3">
31
+ <div className="welcome-entry-icon">
32
+ <Icon className="h-5 w-5" />
33
+ </div>
34
+ <div className="min-w-0">
35
+ <p className="welcome-entry-title">{title}</p>
36
+ <p className="welcome-entry-description">
37
+ {description}
38
+ </p>
39
+ </div>
40
+ </div>
41
+ <ChevronRight className="welcome-entry-arrow" />
42
+ </Link>
43
+ </CardContent>
44
+ </Card>
45
+ );
46
+ }
47
+
48
+ export default function Welcome({ canRegister = true }: WelcomeProps) {
49
  return (
50
  <>
51
  <Head title="Welcome" />
52
+
53
+ <main className="welcome-shell">
54
+ <AppearanceToggle />
55
+
56
+ <section className="welcome-content">
57
+ <div className="welcome-logo">
58
+ <MessageSquare className="h-9 w-9" />
59
+ </div>
60
+
61
+ <header className="space-y-2 text-center">
62
+ <h1 className="welcome-title">Sevima RAG Hub</h1>
63
+ <p className="welcome-subtitle text-base">
64
+ Pusat kelola dokumen dan Q&amp;A berbasis AI untuk
65
+ lingkungan kampus
66
+ </p>
67
+ </header>
68
+
69
+ <div className="welcome-entries">
70
+ <AuthEntryCard
71
+ href={login()}
72
+ icon={BookOpen}
73
+ title="Masuk sebagai Dosen"
74
+ description="Kelola knowledge base dan dokumen"
75
+ />
76
+ <AuthEntryCard
77
+ href={login()}
78
+ icon={MessageSquare}
79
+ title="Masuk sebagai Mahasiswa"
80
+ description="Tanya jawab materi kuliah dengan AI"
81
+ />
82
+ </div>
83
+
84
+ {canRegister && (
85
+ <p className="welcome-register text-base">
86
+ Belum punya akun?{' '}
87
+ <Link
88
+ href={register()}
89
+ className="welcome-register-link"
90
+ >
91
+ Register
92
+ </Link>
93
+ </p>
94
+ )}
95
+ </section>
96
+ </main>
97
  </>
98
  );
99
+ }
 
 
routes/web.php CHANGED
@@ -7,8 +7,12 @@
7
  'canRegister' => Features::enabled(Features::registration()),
8
  ])->name('home');
9
 
10
- Route::middleware(['auth', 'verified'])->group(function () {
11
- Route::inertia('dashboard', 'dashboard')->name('dashboard');
12
- });
 
 
 
 
13
 
14
  require __DIR__.'/settings.php';
 
7
  'canRegister' => Features::enabled(Features::registration()),
8
  ])->name('home');
9
 
10
+ Route::inertia('dashboard', 'dashboard')->name('dashboard');
11
+
12
+ Route::inertia('register/mahasiswa', 'auth/register-mahasiswa')->name('register.mahasiswa');
13
+
14
+ Route::inertia('register/dosen', 'auth/register-dosen')->name('register.dosen');
15
+
16
+ Route::inertia('mahasiswa', 'mahasiswa')->name('mahasiswa');
17
 
18
  require __DIR__.'/settings.php';
sevima-raghub.json ADDED
@@ -0,0 +1,2060 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "fca-api",
5
+ "version": "0.0.1"
6
+ },
7
+ "paths": {
8
+ "/": {
9
+ "get": {
10
+ "summary": "Root",
11
+ "operationId": "root__get",
12
+ "responses": {
13
+ "200": {
14
+ "description": "Successful Response",
15
+ "content": {
16
+ "application/json": {
17
+ "schema": {}
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ },
24
+ "/api/v1/auth/register": {
25
+ "post": {
26
+ "tags": ["v1", "auth"],
27
+ "summary": "Register",
28
+ "description": "Register a new user (student or lecturer). Source: openapi-spec /auth/register",
29
+ "operationId": "register_api_v1_auth_register_post",
30
+ "requestBody": {
31
+ "content": {
32
+ "application/json": {
33
+ "schema": {
34
+ "$ref": "#/components/schemas/RegisterRequest"
35
+ }
36
+ }
37
+ },
38
+ "required": true
39
+ },
40
+ "responses": {
41
+ "201": {
42
+ "description": "Successful Response",
43
+ "content": {
44
+ "application/json": {
45
+ "schema": {
46
+ "$ref": "#/components/schemas/RegisterResponse"
47
+ }
48
+ }
49
+ }
50
+ },
51
+ "422": {
52
+ "description": "Validation Error",
53
+ "content": {
54
+ "application/json": {
55
+ "schema": {
56
+ "$ref": "#/components/schemas/HTTPValidationError"
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ },
64
+ "/api/v1/auth/login": {
65
+ "post": {
66
+ "tags": ["v1", "auth"],
67
+ "summary": "Login",
68
+ "description": "Login with email + password and receive a JWT. Source: openapi-spec /auth/login",
69
+ "operationId": "login_api_v1_auth_login_post",
70
+ "requestBody": {
71
+ "content": {
72
+ "application/json": {
73
+ "schema": {
74
+ "$ref": "#/components/schemas/LoginRequest"
75
+ }
76
+ }
77
+ },
78
+ "required": true
79
+ },
80
+ "responses": {
81
+ "200": {
82
+ "description": "Successful Response",
83
+ "content": {
84
+ "application/json": {
85
+ "schema": {
86
+ "$ref": "#/components/schemas/LoginResponse"
87
+ }
88
+ }
89
+ }
90
+ },
91
+ "422": {
92
+ "description": "Validation Error",
93
+ "content": {
94
+ "application/json": {
95
+ "schema": {
96
+ "$ref": "#/components/schemas/HTTPValidationError"
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ },
104
+ "/api/v1/auth/login/api-key": {
105
+ "post": {
106
+ "tags": ["v1", "auth"],
107
+ "summary": "Login With Api Key",
108
+ "description": "Exchange an API key for a JWT. Source: openapi-spec /auth/login/api-key",
109
+ "operationId": "login_with_api_key_api_v1_auth_login_api_key_post",
110
+ "requestBody": {
111
+ "content": {
112
+ "application/json": {
113
+ "schema": {
114
+ "$ref": "#/components/schemas/ApiKeyLoginRequest"
115
+ }
116
+ }
117
+ },
118
+ "required": true
119
+ },
120
+ "responses": {
121
+ "200": {
122
+ "description": "Successful Response",
123
+ "content": {
124
+ "application/json": {
125
+ "schema": {
126
+ "$ref": "#/components/schemas/LoginResponse"
127
+ }
128
+ }
129
+ }
130
+ },
131
+ "422": {
132
+ "description": "Validation Error",
133
+ "content": {
134
+ "application/json": {
135
+ "schema": {
136
+ "$ref": "#/components/schemas/HTTPValidationError"
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ },
144
+ "/api/v1/auth/api-keys": {
145
+ "post": {
146
+ "tags": ["v1", "auth"],
147
+ "summary": "Generate Api Key",
148
+ "description": "Generate a new API key. Restricted to lecturers and admins. Source: openapi-spec /auth/api-keys",
149
+ "operationId": "generate_api_key_api_v1_auth_api_keys_post",
150
+ "responses": {
151
+ "201": {
152
+ "description": "Successful Response",
153
+ "content": {
154
+ "application/json": {
155
+ "schema": {
156
+ "$ref": "#/components/schemas/ApiKeyGenerateResponse"
157
+ }
158
+ }
159
+ }
160
+ }
161
+ },
162
+ "security": [
163
+ {
164
+ "JWTBearer": []
165
+ }
166
+ ]
167
+ }
168
+ },
169
+ "/api/v1/documents/upload": {
170
+ "post": {
171
+ "tags": ["v1", "documents"],
172
+ "summary": "Upload course material document",
173
+ "description": "Upload a file to be used as a knowledge base. Supported formats: PDF, PPT, PPTX, TXT, XLS, XLSX. Maximum size: 100 MB. Asynchronous — use the status endpoint to check progress.",
174
+ "operationId": "upload_document_api_v1_documents_upload_post",
175
+ "requestBody": {
176
+ "content": {
177
+ "multipart/form-data": {
178
+ "schema": {
179
+ "$ref": "#/components/schemas/Body_upload_document_api_v1_documents_upload_post"
180
+ }
181
+ }
182
+ },
183
+ "required": true
184
+ },
185
+ "responses": {
186
+ "202": {
187
+ "description": "Successful Response",
188
+ "content": {
189
+ "application/json": {
190
+ "schema": {
191
+ "$ref": "#/components/schemas/UploadDocumentResponse"
192
+ }
193
+ }
194
+ }
195
+ },
196
+ "422": {
197
+ "description": "Validation Error",
198
+ "content": {
199
+ "application/json": {
200
+ "schema": {
201
+ "$ref": "#/components/schemas/HTTPValidationError"
202
+ }
203
+ }
204
+ }
205
+ }
206
+ },
207
+ "security": [
208
+ {
209
+ "JWTBearer": []
210
+ }
211
+ ]
212
+ }
213
+ },
214
+ "/api/v1/courses/{course_id}/documents": {
215
+ "get": {
216
+ "tags": ["v1", "documents"],
217
+ "summary": "List documents per course",
218
+ "description": "Get all knowledge base documents for a specific course.",
219
+ "operationId": "list_documents_by_course_api_v1_courses__course_id__documents_get",
220
+ "parameters": [
221
+ {
222
+ "required": true,
223
+ "schema": {
224
+ "type": "string",
225
+ "title": "Course Id"
226
+ },
227
+ "name": "course_id",
228
+ "in": "path"
229
+ }
230
+ ],
231
+ "responses": {
232
+ "200": {
233
+ "description": "Successful Response",
234
+ "content": {
235
+ "application/json": {
236
+ "schema": {
237
+ "$ref": "#/components/schemas/DocumentListResponse"
238
+ }
239
+ }
240
+ }
241
+ },
242
+ "422": {
243
+ "description": "Validation Error",
244
+ "content": {
245
+ "application/json": {
246
+ "schema": {
247
+ "$ref": "#/components/schemas/HTTPValidationError"
248
+ }
249
+ }
250
+ }
251
+ }
252
+ },
253
+ "security": [
254
+ {
255
+ "JWTBearer": []
256
+ }
257
+ ]
258
+ }
259
+ },
260
+ "/api/v1/courses": {
261
+ "get": {
262
+ "tags": ["v1", "courses"],
263
+ "summary": "List courses",
264
+ "operationId": "get_courses_api_v1_courses_get",
265
+ "parameters": [
266
+ {
267
+ "required": false,
268
+ "schema": {
269
+ "type": "integer",
270
+ "minimum": 1.0,
271
+ "title": "Page",
272
+ "default": 1
273
+ },
274
+ "name": "page",
275
+ "in": "query"
276
+ },
277
+ {
278
+ "required": false,
279
+ "schema": {
280
+ "type": "integer",
281
+ "minimum": 1.0,
282
+ "title": "Limit",
283
+ "default": 10
284
+ },
285
+ "name": "limit",
286
+ "in": "query"
287
+ }
288
+ ],
289
+ "responses": {
290
+ "200": {
291
+ "description": "Successful Response",
292
+ "content": {
293
+ "application/json": {
294
+ "schema": {
295
+ "$ref": "#/components/schemas/CourseListResponse"
296
+ }
297
+ }
298
+ }
299
+ },
300
+ "422": {
301
+ "description": "Validation Error",
302
+ "content": {
303
+ "application/json": {
304
+ "schema": {
305
+ "$ref": "#/components/schemas/HTTPValidationError"
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ },
312
+ "post": {
313
+ "tags": ["v1", "courses"],
314
+ "summary": "Create course",
315
+ "operationId": "create_course_api_v1_courses_post",
316
+ "requestBody": {
317
+ "content": {
318
+ "application/json": {
319
+ "schema": {
320
+ "$ref": "#/components/schemas/CreateCourseRequest"
321
+ }
322
+ }
323
+ },
324
+ "required": true
325
+ },
326
+ "responses": {
327
+ "201": {
328
+ "description": "Successful Response",
329
+ "content": {
330
+ "application/json": {
331
+ "schema": {
332
+ "$ref": "#/components/schemas/CourseResponse"
333
+ }
334
+ }
335
+ }
336
+ },
337
+ "422": {
338
+ "description": "Validation Error",
339
+ "content": {
340
+ "application/json": {
341
+ "schema": {
342
+ "$ref": "#/components/schemas/HTTPValidationError"
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ }
349
+ },
350
+ "/api/v1/courses/{course_id}": {
351
+ "get": {
352
+ "tags": ["v1", "courses"],
353
+ "summary": "Get course detail",
354
+ "operationId": "get_course_api_v1_courses__course_id__get",
355
+ "parameters": [
356
+ {
357
+ "required": true,
358
+ "schema": {
359
+ "type": "integer",
360
+ "title": "Course Id"
361
+ },
362
+ "name": "course_id",
363
+ "in": "path"
364
+ }
365
+ ],
366
+ "responses": {
367
+ "200": {
368
+ "description": "Successful Response",
369
+ "content": {
370
+ "application/json": {
371
+ "schema": {
372
+ "$ref": "#/components/schemas/CourseResponse"
373
+ }
374
+ }
375
+ }
376
+ },
377
+ "422": {
378
+ "description": "Validation Error",
379
+ "content": {
380
+ "application/json": {
381
+ "schema": {
382
+ "$ref": "#/components/schemas/HTTPValidationError"
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ },
389
+ "put": {
390
+ "tags": ["v1", "courses"],
391
+ "summary": "Update course",
392
+ "operationId": "update_course_api_v1_courses__course_id__put",
393
+ "parameters": [
394
+ {
395
+ "required": true,
396
+ "schema": {
397
+ "type": "integer",
398
+ "title": "Course Id"
399
+ },
400
+ "name": "course_id",
401
+ "in": "path"
402
+ }
403
+ ],
404
+ "requestBody": {
405
+ "content": {
406
+ "application/json": {
407
+ "schema": {
408
+ "$ref": "#/components/schemas/UpdateCourseRequest"
409
+ }
410
+ }
411
+ },
412
+ "required": true
413
+ },
414
+ "responses": {
415
+ "200": {
416
+ "description": "Successful Response",
417
+ "content": {
418
+ "application/json": {
419
+ "schema": {
420
+ "$ref": "#/components/schemas/CourseResponse"
421
+ }
422
+ }
423
+ }
424
+ },
425
+ "422": {
426
+ "description": "Validation Error",
427
+ "content": {
428
+ "application/json": {
429
+ "schema": {
430
+ "$ref": "#/components/schemas/HTTPValidationError"
431
+ }
432
+ }
433
+ }
434
+ }
435
+ }
436
+ },
437
+ "delete": {
438
+ "tags": ["v1", "courses"],
439
+ "summary": "Delete course",
440
+ "operationId": "delete_course_api_v1_courses__course_id__delete",
441
+ "parameters": [
442
+ {
443
+ "required": true,
444
+ "schema": {
445
+ "type": "integer",
446
+ "title": "Course Id"
447
+ },
448
+ "name": "course_id",
449
+ "in": "path"
450
+ }
451
+ ],
452
+ "responses": {
453
+ "200": {
454
+ "description": "Successful Response",
455
+ "content": {
456
+ "application/json": {
457
+ "schema": {}
458
+ }
459
+ }
460
+ },
461
+ "422": {
462
+ "description": "Validation Error",
463
+ "content": {
464
+ "application/json": {
465
+ "schema": {
466
+ "$ref": "#/components/schemas/HTTPValidationError"
467
+ }
468
+ }
469
+ }
470
+ }
471
+ }
472
+ }
473
+ },
474
+ "/api/v1/user": {
475
+ "get": {
476
+ "tags": ["v1", "user"],
477
+ "summary": "Get User List",
478
+ "operationId": "get_user_list_api_v1_user_get",
479
+ "parameters": [
480
+ {
481
+ "required": false,
482
+ "schema": {
483
+ "type": "string",
484
+ "title": "Email"
485
+ },
486
+ "name": "email",
487
+ "in": "query"
488
+ },
489
+ {
490
+ "required": false,
491
+ "schema": {
492
+ "type": "string",
493
+ "title": "User Token"
494
+ },
495
+ "name": "user_token",
496
+ "in": "query"
497
+ },
498
+ {
499
+ "required": false,
500
+ "schema": {
501
+ "type": "string",
502
+ "title": "Name"
503
+ },
504
+ "name": "name",
505
+ "in": "query"
506
+ },
507
+ {
508
+ "required": false,
509
+ "schema": {
510
+ "$ref": "#/components/schemas/app__schema__user_schema__Role"
511
+ },
512
+ "name": "role",
513
+ "in": "query"
514
+ },
515
+ {
516
+ "required": false,
517
+ "schema": {
518
+ "type": "string",
519
+ "title": "Identity Number"
520
+ },
521
+ "name": "identity_number",
522
+ "in": "query"
523
+ },
524
+ {
525
+ "required": false,
526
+ "schema": {
527
+ "type": "boolean",
528
+ "title": "Is Active"
529
+ },
530
+ "name": "is_active",
531
+ "in": "query"
532
+ },
533
+ {
534
+ "required": false,
535
+ "schema": {
536
+ "type": "boolean",
537
+ "title": "Is Superuser"
538
+ },
539
+ "name": "is_superuser",
540
+ "in": "query"
541
+ },
542
+ {
543
+ "required": false,
544
+ "schema": {
545
+ "type": "string",
546
+ "title": "Ordering"
547
+ },
548
+ "name": "ordering",
549
+ "in": "query"
550
+ },
551
+ {
552
+ "required": false,
553
+ "schema": {
554
+ "type": "integer",
555
+ "title": "Page"
556
+ },
557
+ "name": "page",
558
+ "in": "query"
559
+ },
560
+ {
561
+ "required": false,
562
+ "schema": {
563
+ "anyOf": [
564
+ {
565
+ "type": "integer"
566
+ },
567
+ {
568
+ "type": "string"
569
+ }
570
+ ],
571
+ "title": "Page Size"
572
+ },
573
+ "name": "page_size",
574
+ "in": "query"
575
+ },
576
+ {
577
+ "required": false,
578
+ "schema": {
579
+ "type": "string",
580
+ "title": "Email Eq"
581
+ },
582
+ "name": "email__eq",
583
+ "in": "query"
584
+ },
585
+ {
586
+ "required": false,
587
+ "schema": {
588
+ "type": "string",
589
+ "title": "Identity Number Eq"
590
+ },
591
+ "name": "identity_number__eq",
592
+ "in": "query"
593
+ }
594
+ ],
595
+ "responses": {
596
+ "200": {
597
+ "description": "Successful Response",
598
+ "content": {
599
+ "application/json": {
600
+ "schema": {
601
+ "$ref": "#/components/schemas/FindUserResult"
602
+ }
603
+ }
604
+ }
605
+ },
606
+ "422": {
607
+ "description": "Validation Error",
608
+ "content": {
609
+ "application/json": {
610
+ "schema": {
611
+ "$ref": "#/components/schemas/HTTPValidationError"
612
+ }
613
+ }
614
+ }
615
+ }
616
+ },
617
+ "security": [
618
+ {
619
+ "JWTBearer": []
620
+ },
621
+ {
622
+ "JWTBearer": []
623
+ }
624
+ ]
625
+ },
626
+ "post": {
627
+ "tags": ["v1", "user"],
628
+ "summary": "Create User",
629
+ "operationId": "create_user_api_v1_user_post",
630
+ "requestBody": {
631
+ "content": {
632
+ "application/json": {
633
+ "schema": {
634
+ "$ref": "#/components/schemas/UpsertUser"
635
+ }
636
+ }
637
+ },
638
+ "required": true
639
+ },
640
+ "responses": {
641
+ "200": {
642
+ "description": "Successful Response",
643
+ "content": {
644
+ "application/json": {
645
+ "schema": {
646
+ "$ref": "#/components/schemas/User"
647
+ }
648
+ }
649
+ }
650
+ },
651
+ "422": {
652
+ "description": "Validation Error",
653
+ "content": {
654
+ "application/json": {
655
+ "schema": {
656
+ "$ref": "#/components/schemas/HTTPValidationError"
657
+ }
658
+ }
659
+ }
660
+ }
661
+ },
662
+ "security": [
663
+ {
664
+ "JWTBearer": []
665
+ },
666
+ {
667
+ "JWTBearer": []
668
+ }
669
+ ]
670
+ }
671
+ },
672
+ "/api/v1/user/{user_id}": {
673
+ "get": {
674
+ "tags": ["v1", "user"],
675
+ "summary": "Get User",
676
+ "operationId": "get_user_api_v1_user__user_id__get",
677
+ "parameters": [
678
+ {
679
+ "required": true,
680
+ "schema": {
681
+ "type": "integer",
682
+ "title": "User Id"
683
+ },
684
+ "name": "user_id",
685
+ "in": "path"
686
+ }
687
+ ],
688
+ "responses": {
689
+ "200": {
690
+ "description": "Successful Response",
691
+ "content": {
692
+ "application/json": {
693
+ "schema": {
694
+ "$ref": "#/components/schemas/User"
695
+ }
696
+ }
697
+ }
698
+ },
699
+ "422": {
700
+ "description": "Validation Error",
701
+ "content": {
702
+ "application/json": {
703
+ "schema": {
704
+ "$ref": "#/components/schemas/HTTPValidationError"
705
+ }
706
+ }
707
+ }
708
+ }
709
+ },
710
+ "security": [
711
+ {
712
+ "JWTBearer": []
713
+ },
714
+ {
715
+ "JWTBearer": []
716
+ }
717
+ ]
718
+ },
719
+ "delete": {
720
+ "tags": ["v1", "user"],
721
+ "summary": "Delete User",
722
+ "operationId": "delete_user_api_v1_user__user_id__delete",
723
+ "parameters": [
724
+ {
725
+ "required": true,
726
+ "schema": {
727
+ "type": "integer",
728
+ "title": "User Id"
729
+ },
730
+ "name": "user_id",
731
+ "in": "path"
732
+ }
733
+ ],
734
+ "responses": {
735
+ "200": {
736
+ "description": "Successful Response",
737
+ "content": {
738
+ "application/json": {
739
+ "schema": {
740
+ "$ref": "#/components/schemas/Blank"
741
+ }
742
+ }
743
+ }
744
+ },
745
+ "422": {
746
+ "description": "Validation Error",
747
+ "content": {
748
+ "application/json": {
749
+ "schema": {
750
+ "$ref": "#/components/schemas/HTTPValidationError"
751
+ }
752
+ }
753
+ }
754
+ }
755
+ },
756
+ "security": [
757
+ {
758
+ "JWTBearer": []
759
+ },
760
+ {
761
+ "JWTBearer": []
762
+ }
763
+ ]
764
+ },
765
+ "patch": {
766
+ "tags": ["v1", "user"],
767
+ "summary": "Update User",
768
+ "operationId": "update_user_api_v1_user__user_id__patch",
769
+ "parameters": [
770
+ {
771
+ "required": true,
772
+ "schema": {
773
+ "type": "integer",
774
+ "title": "User Id"
775
+ },
776
+ "name": "user_id",
777
+ "in": "path"
778
+ }
779
+ ],
780
+ "requestBody": {
781
+ "content": {
782
+ "application/json": {
783
+ "schema": {
784
+ "$ref": "#/components/schemas/UpsertUser"
785
+ }
786
+ }
787
+ },
788
+ "required": true
789
+ },
790
+ "responses": {
791
+ "200": {
792
+ "description": "Successful Response",
793
+ "content": {
794
+ "application/json": {
795
+ "schema": {
796
+ "$ref": "#/components/schemas/User"
797
+ }
798
+ }
799
+ }
800
+ },
801
+ "422": {
802
+ "description": "Validation Error",
803
+ "content": {
804
+ "application/json": {
805
+ "schema": {
806
+ "$ref": "#/components/schemas/HTTPValidationError"
807
+ }
808
+ }
809
+ }
810
+ }
811
+ },
812
+ "security": [
813
+ {
814
+ "JWTBearer": []
815
+ },
816
+ {
817
+ "JWTBearer": []
818
+ }
819
+ ]
820
+ }
821
+ },
822
+ "/api/v1/chats/sessions": {
823
+ "get": {
824
+ "tags": ["v1", "Chat"],
825
+ "summary": "Get Chat Sessions",
826
+ "description": "List chat sessions with pagination.",
827
+ "operationId": "get_chat_sessions_api_v1_chats_sessions_get",
828
+ "parameters": [
829
+ {
830
+ "required": false,
831
+ "schema": {
832
+ "type": "integer",
833
+ "minimum": 1.0,
834
+ "title": "Page",
835
+ "default": 1
836
+ },
837
+ "name": "page",
838
+ "in": "query"
839
+ },
840
+ {
841
+ "required": false,
842
+ "schema": {
843
+ "type": "integer",
844
+ "minimum": 1.0,
845
+ "title": "Limit",
846
+ "default": 10
847
+ },
848
+ "name": "limit",
849
+ "in": "query"
850
+ },
851
+ {
852
+ "required": false,
853
+ "schema": {
854
+ "type": "string",
855
+ "title": "Course Id"
856
+ },
857
+ "name": "course_id",
858
+ "in": "query"
859
+ }
860
+ ],
861
+ "responses": {
862
+ "200": {
863
+ "description": "Successful Response",
864
+ "content": {
865
+ "application/json": {
866
+ "schema": {
867
+ "$ref": "#/components/schemas/ChatSessionListResponse"
868
+ }
869
+ }
870
+ }
871
+ },
872
+ "422": {
873
+ "description": "Validation Error",
874
+ "content": {
875
+ "application/json": {
876
+ "schema": {
877
+ "$ref": "#/components/schemas/HTTPValidationError"
878
+ }
879
+ }
880
+ }
881
+ }
882
+ },
883
+ "security": [
884
+ {
885
+ "JWTBearer": []
886
+ }
887
+ ]
888
+ },
889
+ "post": {
890
+ "tags": ["v1", "Chat"],
891
+ "summary": "Create Chat Session",
892
+ "description": "Create a new chat session.",
893
+ "operationId": "create_chat_session_api_v1_chats_sessions_post",
894
+ "requestBody": {
895
+ "content": {
896
+ "application/json": {
897
+ "schema": {
898
+ "$ref": "#/components/schemas/CreateChatSessionRequest"
899
+ }
900
+ }
901
+ },
902
+ "required": true
903
+ },
904
+ "responses": {
905
+ "201": {
906
+ "description": "Successful Response",
907
+ "content": {
908
+ "application/json": {
909
+ "schema": {
910
+ "$ref": "#/components/schemas/ChatSessionResponse"
911
+ }
912
+ }
913
+ }
914
+ },
915
+ "422": {
916
+ "description": "Validation Error",
917
+ "content": {
918
+ "application/json": {
919
+ "schema": {
920
+ "$ref": "#/components/schemas/HTTPValidationError"
921
+ }
922
+ }
923
+ }
924
+ }
925
+ },
926
+ "security": [
927
+ {
928
+ "JWTBearer": []
929
+ }
930
+ ]
931
+ }
932
+ },
933
+ "/api/v1/chats/sessions/{session_id}": {
934
+ "get": {
935
+ "tags": ["v1", "Chat"],
936
+ "summary": "Get Chat Session",
937
+ "description": "Get chat session details.",
938
+ "operationId": "get_chat_session_api_v1_chats_sessions__session_id__get",
939
+ "parameters": [
940
+ {
941
+ "required": true,
942
+ "schema": {
943
+ "type": "string",
944
+ "title": "Session Id"
945
+ },
946
+ "name": "session_id",
947
+ "in": "path"
948
+ }
949
+ ],
950
+ "responses": {
951
+ "200": {
952
+ "description": "Successful Response",
953
+ "content": {
954
+ "application/json": {
955
+ "schema": {
956
+ "$ref": "#/components/schemas/ChatSessionResponse"
957
+ }
958
+ }
959
+ }
960
+ },
961
+ "422": {
962
+ "description": "Validation Error",
963
+ "content": {
964
+ "application/json": {
965
+ "schema": {
966
+ "$ref": "#/components/schemas/HTTPValidationError"
967
+ }
968
+ }
969
+ }
970
+ }
971
+ },
972
+ "security": [
973
+ {
974
+ "JWTBearer": []
975
+ }
976
+ ]
977
+ },
978
+ "delete": {
979
+ "tags": ["v1", "Chat"],
980
+ "summary": "Delete Chat Session",
981
+ "description": "Delete a chat session.",
982
+ "operationId": "delete_chat_session_api_v1_chats_sessions__session_id__delete",
983
+ "parameters": [
984
+ {
985
+ "required": true,
986
+ "schema": {
987
+ "type": "string",
988
+ "title": "Session Id"
989
+ },
990
+ "name": "session_id",
991
+ "in": "path"
992
+ }
993
+ ],
994
+ "responses": {
995
+ "200": {
996
+ "description": "Successful Response",
997
+ "content": {
998
+ "application/json": {
999
+ "schema": {}
1000
+ }
1001
+ }
1002
+ },
1003
+ "422": {
1004
+ "description": "Validation Error",
1005
+ "content": {
1006
+ "application/json": {
1007
+ "schema": {
1008
+ "$ref": "#/components/schemas/HTTPValidationError"
1009
+ }
1010
+ }
1011
+ }
1012
+ }
1013
+ },
1014
+ "security": [
1015
+ {
1016
+ "JWTBearer": []
1017
+ }
1018
+ ]
1019
+ }
1020
+ },
1021
+ "/api/v1/chats/sessions/{session_id}/messages": {
1022
+ "get": {
1023
+ "tags": ["v1", "Chat"],
1024
+ "summary": "Get Chat History",
1025
+ "description": "Get all messages in a session.",
1026
+ "operationId": "get_chat_history_api_v1_chats_sessions__session_id__messages_get",
1027
+ "parameters": [
1028
+ {
1029
+ "required": true,
1030
+ "schema": {
1031
+ "type": "string",
1032
+ "title": "Session Id"
1033
+ },
1034
+ "name": "session_id",
1035
+ "in": "path"
1036
+ }
1037
+ ],
1038
+ "responses": {
1039
+ "200": {
1040
+ "description": "Successful Response",
1041
+ "content": {
1042
+ "application/json": {
1043
+ "schema": {
1044
+ "$ref": "#/components/schemas/ChatHistoryResponse"
1045
+ }
1046
+ }
1047
+ }
1048
+ },
1049
+ "422": {
1050
+ "description": "Validation Error",
1051
+ "content": {
1052
+ "application/json": {
1053
+ "schema": {
1054
+ "$ref": "#/components/schemas/HTTPValidationError"
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+ },
1060
+ "security": [
1061
+ {
1062
+ "JWTBearer": []
1063
+ }
1064
+ ]
1065
+ },
1066
+ "post": {
1067
+ "tags": ["v1", "Chat"],
1068
+ "summary": "Send Message Rest",
1069
+ "description": "Send a message to AI via REST fallback.",
1070
+ "operationId": "send_message_rest_api_v1_chats_sessions__session_id__messages_post",
1071
+ "parameters": [
1072
+ {
1073
+ "required": true,
1074
+ "schema": {
1075
+ "type": "string",
1076
+ "title": "Session Id"
1077
+ },
1078
+ "name": "session_id",
1079
+ "in": "path"
1080
+ }
1081
+ ],
1082
+ "requestBody": {
1083
+ "content": {
1084
+ "application/json": {
1085
+ "schema": {
1086
+ "$ref": "#/components/schemas/ChatRESTRequest"
1087
+ }
1088
+ }
1089
+ },
1090
+ "required": true
1091
+ },
1092
+ "responses": {
1093
+ "200": {
1094
+ "description": "Successful Response",
1095
+ "content": {
1096
+ "application/json": {
1097
+ "schema": {
1098
+ "$ref": "#/components/schemas/ChatMessageResponse"
1099
+ }
1100
+ }
1101
+ }
1102
+ },
1103
+ "422": {
1104
+ "description": "Validation Error",
1105
+ "content": {
1106
+ "application/json": {
1107
+ "schema": {
1108
+ "$ref": "#/components/schemas/HTTPValidationError"
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+ },
1114
+ "security": [
1115
+ {
1116
+ "JWTBearer": []
1117
+ }
1118
+ ]
1119
+ }
1120
+ },
1121
+ "/api/v1/ai/query": {
1122
+ "post": {
1123
+ "tags": ["v1", "Chat"],
1124
+ "summary": "Query Ai Direct",
1125
+ "description": "Send a direct question to AI (without session history).",
1126
+ "operationId": "query_ai_direct_api_v1_ai_query_post",
1127
+ "requestBody": {
1128
+ "content": {
1129
+ "application/json": {
1130
+ "schema": {
1131
+ "$ref": "#/components/schemas/ChatQueryRequest"
1132
+ }
1133
+ }
1134
+ },
1135
+ "required": true
1136
+ },
1137
+ "responses": {
1138
+ "200": {
1139
+ "description": "Successful Response",
1140
+ "content": {
1141
+ "application/json": {
1142
+ "schema": {
1143
+ "$ref": "#/components/schemas/ChatQueryResponse"
1144
+ }
1145
+ }
1146
+ }
1147
+ },
1148
+ "422": {
1149
+ "description": "Validation Error",
1150
+ "content": {
1151
+ "application/json": {
1152
+ "schema": {
1153
+ "$ref": "#/components/schemas/HTTPValidationError"
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ },
1159
+ "security": [
1160
+ {
1161
+ "JWTBearer": []
1162
+ }
1163
+ ]
1164
+ }
1165
+ }
1166
+ },
1167
+ "components": {
1168
+ "schemas": {
1169
+ "ApiKeyGenerateResponse": {
1170
+ "properties": {
1171
+ "api_key": {
1172
+ "type": "string",
1173
+ "title": "Api Key"
1174
+ },
1175
+ "expires_at": {
1176
+ "type": "string",
1177
+ "format": "date-time",
1178
+ "title": "Expires At"
1179
+ }
1180
+ },
1181
+ "type": "object",
1182
+ "required": ["api_key"],
1183
+ "title": "ApiKeyGenerateResponse",
1184
+ "description": "POST /auth/api-keys — 201 response. Raw key returned ONCE."
1185
+ },
1186
+ "ApiKeyLoginRequest": {
1187
+ "properties": {
1188
+ "api_key": {
1189
+ "type": "string",
1190
+ "title": "Api Key"
1191
+ }
1192
+ },
1193
+ "type": "object",
1194
+ "required": ["api_key"],
1195
+ "title": "ApiKeyLoginRequest",
1196
+ "description": "POST /auth/login/api-key — body."
1197
+ },
1198
+ "Blank": {
1199
+ "properties": {},
1200
+ "type": "object",
1201
+ "title": "Blank"
1202
+ },
1203
+ "Body_upload_document_api_v1_documents_upload_post": {
1204
+ "properties": {
1205
+ "file": {
1206
+ "type": "string",
1207
+ "format": "binary",
1208
+ "title": "File",
1209
+ "description": "Course material file"
1210
+ },
1211
+ "course_id": {
1212
+ "type": "string",
1213
+ "title": "Course Id",
1214
+ "description": "Target course UUID"
1215
+ }
1216
+ },
1217
+ "type": "object",
1218
+ "required": ["file", "course_id"],
1219
+ "title": "Body_upload_document_api_v1_documents_upload_post"
1220
+ },
1221
+ "ChatHistoryResponse": {
1222
+ "properties": {
1223
+ "data": {
1224
+ "items": {
1225
+ "$ref": "#/components/schemas/ChatMessageResponse"
1226
+ },
1227
+ "type": "array",
1228
+ "title": "Data"
1229
+ }
1230
+ },
1231
+ "type": "object",
1232
+ "required": ["data"],
1233
+ "title": "ChatHistoryResponse",
1234
+ "description": "Response format for GET /sessions/{session_id}/messages."
1235
+ },
1236
+ "ChatMessageResponse": {
1237
+ "properties": {
1238
+ "uuid_id": {
1239
+ "type": "string",
1240
+ "title": "Uuid Id",
1241
+ "description": "Public UUID of the message"
1242
+ },
1243
+ "role": {
1244
+ "type": "string",
1245
+ "title": "Role"
1246
+ },
1247
+ "content": {
1248
+ "type": "string",
1249
+ "title": "Content"
1250
+ },
1251
+ "sources": {
1252
+ "items": {
1253
+ "$ref": "#/components/schemas/DocumentSourceDTO"
1254
+ },
1255
+ "type": "array",
1256
+ "title": "Sources",
1257
+ "default": []
1258
+ },
1259
+ "created_at": {
1260
+ "type": "string",
1261
+ "format": "date-time",
1262
+ "title": "Created At"
1263
+ }
1264
+ },
1265
+ "type": "object",
1266
+ "required": ["uuid_id", "role", "content", "created_at"],
1267
+ "title": "ChatMessageResponse",
1268
+ "description": "Mirrors the message object in OpenAPI spec."
1269
+ },
1270
+ "ChatQueryRequest": {
1271
+ "properties": {
1272
+ "prompt": {
1273
+ "type": "string",
1274
+ "title": "Prompt",
1275
+ "description": "The user\u0027s prompt text."
1276
+ },
1277
+ "course_id": {
1278
+ "type": "string",
1279
+ "title": "Course Id",
1280
+ "description": "UUID of the course context."
1281
+ }
1282
+ },
1283
+ "type": "object",
1284
+ "required": ["prompt", "course_id"],
1285
+ "title": "ChatQueryRequest",
1286
+ "description": "Payload for direct AI query (non-session)."
1287
+ },
1288
+ "ChatQueryResponse": {
1289
+ "properties": {
1290
+ "message_id": {
1291
+ "type": "string",
1292
+ "title": "Message Id"
1293
+ },
1294
+ "role": {
1295
+ "type": "string",
1296
+ "title": "Role",
1297
+ "default": "assistant"
1298
+ },
1299
+ "content": {
1300
+ "type": "string",
1301
+ "title": "Content"
1302
+ },
1303
+ "sources": {
1304
+ "items": {
1305
+ "$ref": "#/components/schemas/DocumentSourceDTO"
1306
+ },
1307
+ "type": "array",
1308
+ "title": "Sources",
1309
+ "default": []
1310
+ },
1311
+ "created_at": {
1312
+ "type": "string",
1313
+ "format": "date-time",
1314
+ "title": "Created At"
1315
+ }
1316
+ },
1317
+ "type": "object",
1318
+ "required": ["message_id", "content", "created_at"],
1319
+ "title": "ChatQueryResponse",
1320
+ "description": "Response format for POST /ai/query (direct query without session)."
1321
+ },
1322
+ "ChatRESTRequest": {
1323
+ "properties": {
1324
+ "content": {
1325
+ "type": "string",
1326
+ "title": "Content",
1327
+ "description": "The user\u0027s prompt text."
1328
+ }
1329
+ },
1330
+ "type": "object",
1331
+ "required": ["content"],
1332
+ "title": "ChatRESTRequest",
1333
+ "description": "Payload for the REST fallback endpoint sending a message to a session."
1334
+ },
1335
+ "ChatSessionListResponse": {
1336
+ "properties": {
1337
+ "data": {
1338
+ "items": {
1339
+ "$ref": "#/components/schemas/ChatSessionResponse"
1340
+ },
1341
+ "type": "array",
1342
+ "title": "Data"
1343
+ },
1344
+ "pagination": {
1345
+ "$ref": "#/components/schemas/PaginationDTO"
1346
+ }
1347
+ },
1348
+ "type": "object",
1349
+ "required": ["data", "pagination"],
1350
+ "title": "ChatSessionListResponse"
1351
+ },
1352
+ "ChatSessionResponse": {
1353
+ "properties": {
1354
+ "uuid_id": {
1355
+ "type": "string",
1356
+ "title": "Uuid Id",
1357
+ "description": "Public UUID of the session."
1358
+ },
1359
+ "user_id": {
1360
+ "type": "string",
1361
+ "title": "User Id",
1362
+ "description": "User\u0027s public UUID (mocked or stringified internally if missing)."
1363
+ },
1364
+ "user_name": {
1365
+ "type": "string",
1366
+ "title": "User Name"
1367
+ },
1368
+ "course_id": {
1369
+ "type": "string",
1370
+ "title": "Course Id"
1371
+ },
1372
+ "course_name": {
1373
+ "type": "string",
1374
+ "title": "Course Name"
1375
+ },
1376
+ "title": {
1377
+ "type": "string",
1378
+ "title": "Title"
1379
+ },
1380
+ "message_count": {
1381
+ "type": "integer",
1382
+ "title": "Message Count"
1383
+ },
1384
+ "last_message_at": {
1385
+ "type": "string",
1386
+ "format": "date-time",
1387
+ "title": "Last Message At"
1388
+ },
1389
+ "created_at": {
1390
+ "type": "string",
1391
+ "format": "date-time",
1392
+ "title": "Created At"
1393
+ },
1394
+ "updated_at": {
1395
+ "type": "string",
1396
+ "format": "date-time",
1397
+ "title": "Updated At"
1398
+ }
1399
+ },
1400
+ "type": "object",
1401
+ "required": [
1402
+ "uuid_id",
1403
+ "user_id",
1404
+ "course_id",
1405
+ "title",
1406
+ "message_count",
1407
+ "created_at",
1408
+ "updated_at"
1409
+ ],
1410
+ "title": "ChatSessionResponse",
1411
+ "description": "Mirrors the ChatSession schema in OpenAPI spec."
1412
+ },
1413
+ "CourseListResponse": {
1414
+ "properties": {
1415
+ "data": {
1416
+ "items": {
1417
+ "$ref": "#/components/schemas/CourseResponse"
1418
+ },
1419
+ "type": "array",
1420
+ "title": "Data"
1421
+ },
1422
+ "total": {
1423
+ "type": "integer",
1424
+ "title": "Total"
1425
+ },
1426
+ "page": {
1427
+ "type": "integer",
1428
+ "title": "Page"
1429
+ },
1430
+ "limit": {
1431
+ "type": "integer",
1432
+ "title": "Limit"
1433
+ }
1434
+ },
1435
+ "type": "object",
1436
+ "required": ["data", "total", "page", "limit"],
1437
+ "title": "CourseListResponse"
1438
+ },
1439
+ "CourseResponse": {
1440
+ "properties": {
1441
+ "id": {
1442
+ "type": "integer",
1443
+ "title": "Id"
1444
+ },
1445
+ "title": {
1446
+ "type": "string",
1447
+ "title": "Title"
1448
+ },
1449
+ "description": {
1450
+ "type": "string",
1451
+ "title": "Description"
1452
+ }
1453
+ },
1454
+ "type": "object",
1455
+ "required": ["id", "title"],
1456
+ "title": "CourseResponse"
1457
+ },
1458
+ "CreateChatSessionRequest": {
1459
+ "properties": {
1460
+ "course_id": {
1461
+ "type": "string",
1462
+ "title": "Course Id",
1463
+ "description": "UUID of the course"
1464
+ },
1465
+ "title": {
1466
+ "type": "string",
1467
+ "title": "Title",
1468
+ "description": "Session title"
1469
+ }
1470
+ },
1471
+ "type": "object",
1472
+ "required": ["course_id", "title"],
1473
+ "title": "CreateChatSessionRequest"
1474
+ },
1475
+ "CreateCourseRequest": {
1476
+ "properties": {
1477
+ "title": {
1478
+ "type": "string",
1479
+ "title": "Title"
1480
+ },
1481
+ "description": {
1482
+ "type": "string",
1483
+ "title": "Description"
1484
+ }
1485
+ },
1486
+ "type": "object",
1487
+ "required": ["title"],
1488
+ "title": "CreateCourseRequest"
1489
+ },
1490
+ "DocumentListResponse": {
1491
+ "properties": {
1492
+ "data": {
1493
+ "items": {
1494
+ "$ref": "#/components/schemas/DocumentResponse"
1495
+ },
1496
+ "type": "array",
1497
+ "title": "Data"
1498
+ }
1499
+ },
1500
+ "type": "object",
1501
+ "required": ["data"],
1502
+ "title": "DocumentListResponse",
1503
+ "description": "GET /courses/{course_id}/documents — 200 response wrapper."
1504
+ },
1505
+ "DocumentResponse": {
1506
+ "properties": {
1507
+ "id": {
1508
+ "type": "string",
1509
+ "title": "Id"
1510
+ },
1511
+ "filename": {
1512
+ "type": "string",
1513
+ "title": "Filename"
1514
+ },
1515
+ "file_type": {
1516
+ "$ref": "#/components/schemas/FileType"
1517
+ },
1518
+ "file_size": {
1519
+ "type": "integer",
1520
+ "title": "File Size"
1521
+ },
1522
+ "status": {
1523
+ "$ref": "#/components/schemas/DocumentStatus"
1524
+ },
1525
+ "course_id": {
1526
+ "type": "string",
1527
+ "title": "Course Id"
1528
+ },
1529
+ "course_name": {
1530
+ "type": "string",
1531
+ "title": "Course Name"
1532
+ },
1533
+ "uploaded_by": {
1534
+ "type": "string",
1535
+ "title": "Uploaded By"
1536
+ },
1537
+ "uploader_name": {
1538
+ "type": "string",
1539
+ "title": "Uploader Name"
1540
+ },
1541
+ "summary": {
1542
+ "type": "string",
1543
+ "title": "Summary"
1544
+ },
1545
+ "chunk_count": {
1546
+ "type": "integer",
1547
+ "title": "Chunk Count"
1548
+ },
1549
+ "error": {
1550
+ "type": "string",
1551
+ "title": "Error"
1552
+ },
1553
+ "created_at": {
1554
+ "type": "string",
1555
+ "format": "date-time",
1556
+ "title": "Created At"
1557
+ },
1558
+ "updated_at": {
1559
+ "type": "string",
1560
+ "format": "date-time",
1561
+ "title": "Updated At"
1562
+ }
1563
+ },
1564
+ "type": "object",
1565
+ "required": [
1566
+ "id",
1567
+ "filename",
1568
+ "file_type",
1569
+ "file_size",
1570
+ "status",
1571
+ "course_id",
1572
+ "uploaded_by",
1573
+ "created_at",
1574
+ "updated_at"
1575
+ ],
1576
+ "title": "DocumentResponse",
1577
+ "description": "Mirrors the OpenAPI `Document` schema exactly (snake_case, all fields).\nConsumers should use `from_document()` to build from a DB record."
1578
+ },
1579
+ "DocumentSourceDTO": {
1580
+ "properties": {
1581
+ "document_id": {
1582
+ "type": "string",
1583
+ "title": "Document Id"
1584
+ },
1585
+ "filename": {
1586
+ "type": "string",
1587
+ "title": "Filename"
1588
+ },
1589
+ "excerpt": {
1590
+ "type": "string",
1591
+ "title": "Excerpt"
1592
+ }
1593
+ },
1594
+ "type": "object",
1595
+ "required": ["document_id", "filename", "excerpt"],
1596
+ "title": "DocumentSourceDTO"
1597
+ },
1598
+ "DocumentStatus": {
1599
+ "type": "string",
1600
+ "enum": ["processing", "ready", "failed"],
1601
+ "title": "DocumentStatus",
1602
+ "description": "An enumeration."
1603
+ },
1604
+ "FileType": {
1605
+ "type": "string",
1606
+ "enum": ["pdf", "ppt", "pptx", "txt", "xls", "xlsx"],
1607
+ "title": "FileType",
1608
+ "description": "An enumeration."
1609
+ },
1610
+ "FindUserResult": {
1611
+ "properties": {
1612
+ "founds": {
1613
+ "items": {
1614
+ "$ref": "#/components/schemas/User"
1615
+ },
1616
+ "type": "array",
1617
+ "title": "Founds"
1618
+ },
1619
+ "search_options": {
1620
+ "$ref": "#/components/schemas/SearchOptions"
1621
+ }
1622
+ },
1623
+ "type": "object",
1624
+ "title": "FindUserResult"
1625
+ },
1626
+ "HTTPValidationError": {
1627
+ "properties": {
1628
+ "detail": {
1629
+ "items": {
1630
+ "$ref": "#/components/schemas/ValidationError"
1631
+ },
1632
+ "type": "array",
1633
+ "title": "Detail"
1634
+ }
1635
+ },
1636
+ "type": "object",
1637
+ "title": "HTTPValidationError"
1638
+ },
1639
+ "LoginRequest": {
1640
+ "properties": {
1641
+ "email": {
1642
+ "type": "string",
1643
+ "title": "Email"
1644
+ },
1645
+ "password": {
1646
+ "type": "string",
1647
+ "title": "Password"
1648
+ }
1649
+ },
1650
+ "type": "object",
1651
+ "required": ["email", "password"],
1652
+ "title": "LoginRequest",
1653
+ "description": "POST /auth/login — body."
1654
+ },
1655
+ "LoginResponse": {
1656
+ "properties": {
1657
+ "token": {
1658
+ "type": "string",
1659
+ "title": "Token"
1660
+ },
1661
+ "user": {
1662
+ "$ref": "#/components/schemas/UserResponse"
1663
+ }
1664
+ },
1665
+ "type": "object",
1666
+ "required": ["token", "user"],
1667
+ "title": "LoginResponse",
1668
+ "description": "POST /auth/login and POST /auth/login/api-key — 200 response."
1669
+ },
1670
+ "PaginationDTO": {
1671
+ "properties": {
1672
+ "page": {
1673
+ "type": "integer",
1674
+ "title": "Page"
1675
+ },
1676
+ "limit": {
1677
+ "type": "integer",
1678
+ "title": "Limit"
1679
+ },
1680
+ "total": {
1681
+ "type": "integer",
1682
+ "title": "Total"
1683
+ },
1684
+ "total_pages": {
1685
+ "type": "integer",
1686
+ "title": "Total Pages"
1687
+ }
1688
+ },
1689
+ "type": "object",
1690
+ "required": ["page", "limit", "total", "total_pages"],
1691
+ "title": "PaginationDTO"
1692
+ },
1693
+ "RegisterRequest": {
1694
+ "properties": {
1695
+ "email": {
1696
+ "type": "string",
1697
+ "title": "Email"
1698
+ },
1699
+ "password": {
1700
+ "type": "string",
1701
+ "minLength": 6,
1702
+ "title": "Password"
1703
+ },
1704
+ "name": {
1705
+ "type": "string",
1706
+ "title": "Name"
1707
+ },
1708
+ "role": {
1709
+ "$ref": "#/components/schemas/app__schema__auth_schema__Role"
1710
+ },
1711
+ "identity_number": {
1712
+ "type": "string",
1713
+ "title": "Identity Number",
1714
+ "description": "Student ID (NIM), Lecturer ID (NIP), or uppercase code followed by digits"
1715
+ }
1716
+ },
1717
+ "type": "object",
1718
+ "required": [
1719
+ "email",
1720
+ "password",
1721
+ "name",
1722
+ "role",
1723
+ "identity_number"
1724
+ ],
1725
+ "title": "RegisterRequest",
1726
+ "description": "POST /auth/register — body."
1727
+ },
1728
+ "RegisterResponse": {
1729
+ "properties": {
1730
+ "message": {
1731
+ "type": "string",
1732
+ "title": "Message"
1733
+ },
1734
+ "user": {
1735
+ "$ref": "#/components/schemas/UserResponse"
1736
+ }
1737
+ },
1738
+ "type": "object",
1739
+ "required": ["message", "user"],
1740
+ "title": "RegisterResponse",
1741
+ "description": "POST /auth/register — 201 response."
1742
+ },
1743
+ "SearchOptions": {
1744
+ "properties": {
1745
+ "ordering": {
1746
+ "type": "string",
1747
+ "title": "Ordering"
1748
+ },
1749
+ "page": {
1750
+ "type": "integer",
1751
+ "title": "Page"
1752
+ },
1753
+ "page_size": {
1754
+ "anyOf": [
1755
+ {
1756
+ "type": "integer"
1757
+ },
1758
+ {
1759
+ "type": "string"
1760
+ }
1761
+ ],
1762
+ "title": "Page Size"
1763
+ },
1764
+ "total_count": {
1765
+ "type": "integer",
1766
+ "title": "Total Count"
1767
+ }
1768
+ },
1769
+ "type": "object",
1770
+ "title": "SearchOptions"
1771
+ },
1772
+ "SignIn": {
1773
+ "properties": {
1774
+ "email__eq": {
1775
+ "type": "string",
1776
+ "title": "Email Eq"
1777
+ },
1778
+ "password": {
1779
+ "type": "string",
1780
+ "minLength": 6,
1781
+ "title": "Password"
1782
+ }
1783
+ },
1784
+ "type": "object",
1785
+ "required": ["email__eq", "password"],
1786
+ "title": "SignIn"
1787
+ },
1788
+ "SignInResponse": {
1789
+ "properties": {
1790
+ "access_token": {
1791
+ "type": "string",
1792
+ "title": "Access Token"
1793
+ },
1794
+ "expiration": {
1795
+ "type": "string",
1796
+ "format": "date-time",
1797
+ "title": "Expiration"
1798
+ },
1799
+ "user_info": {
1800
+ "title": "User Info"
1801
+ }
1802
+ },
1803
+ "type": "object",
1804
+ "required": ["access_token", "expiration"],
1805
+ "title": "SignInResponse"
1806
+ },
1807
+ "SignUp": {
1808
+ "properties": {
1809
+ "email": {
1810
+ "type": "string",
1811
+ "title": "Email"
1812
+ },
1813
+ "password": {
1814
+ "type": "string",
1815
+ "minLength": 6,
1816
+ "title": "Password"
1817
+ },
1818
+ "name": {
1819
+ "type": "string",
1820
+ "title": "Name"
1821
+ },
1822
+ "role": {
1823
+ "$ref": "#/components/schemas/app__schema__auth_schema__Role"
1824
+ },
1825
+ "identity_number": {
1826
+ "type": "string",
1827
+ "title": "Identity Number"
1828
+ }
1829
+ },
1830
+ "type": "object",
1831
+ "required": [
1832
+ "email",
1833
+ "password",
1834
+ "name",
1835
+ "role",
1836
+ "identity_number"
1837
+ ],
1838
+ "title": "SignUp"
1839
+ },
1840
+ "UpdateCourseRequest": {
1841
+ "properties": {
1842
+ "title": {
1843
+ "type": "string",
1844
+ "title": "Title"
1845
+ },
1846
+ "description": {
1847
+ "type": "string",
1848
+ "title": "Description"
1849
+ }
1850
+ },
1851
+ "type": "object",
1852
+ "title": "UpdateCourseRequest"
1853
+ },
1854
+ "UploadDocumentResponse": {
1855
+ "properties": {
1856
+ "document_id": {
1857
+ "type": "string",
1858
+ "title": "Document Id"
1859
+ },
1860
+ "filename": {
1861
+ "type": "string",
1862
+ "title": "Filename"
1863
+ },
1864
+ "status": {
1865
+ "$ref": "#/components/schemas/DocumentStatus"
1866
+ },
1867
+ "message": {
1868
+ "type": "string",
1869
+ "title": "Message"
1870
+ }
1871
+ },
1872
+ "type": "object",
1873
+ "required": ["document_id", "filename", "status", "message"],
1874
+ "title": "UploadDocumentResponse",
1875
+ "description": "POST /documents/upload — 202 Accepted response.\nSource: openapi-spec.yml /documents/upload 202 schema"
1876
+ },
1877
+ "UpsertUser": {
1878
+ "properties": {
1879
+ "email": {
1880
+ "type": "string",
1881
+ "title": "Email"
1882
+ },
1883
+ "user_token": {
1884
+ "type": "string",
1885
+ "title": "User Token"
1886
+ },
1887
+ "name": {
1888
+ "type": "string",
1889
+ "title": "Name"
1890
+ },
1891
+ "role": {
1892
+ "$ref": "#/components/schemas/app__schema__user_schema__Role"
1893
+ },
1894
+ "identity_number": {
1895
+ "type": "string",
1896
+ "title": "Identity Number"
1897
+ },
1898
+ "is_active": {
1899
+ "type": "boolean",
1900
+ "title": "Is Active"
1901
+ },
1902
+ "is_superuser": {
1903
+ "type": "boolean",
1904
+ "title": "Is Superuser"
1905
+ }
1906
+ },
1907
+ "type": "object",
1908
+ "title": "UpsertUser"
1909
+ },
1910
+ "User": {
1911
+ "properties": {
1912
+ "email": {
1913
+ "type": "string",
1914
+ "title": "Email"
1915
+ },
1916
+ "user_token": {
1917
+ "type": "string",
1918
+ "title": "User Token"
1919
+ },
1920
+ "name": {
1921
+ "type": "string",
1922
+ "title": "Name"
1923
+ },
1924
+ "role": {
1925
+ "$ref": "#/components/schemas/app__schema__user_schema__Role"
1926
+ },
1927
+ "identity_number": {
1928
+ "type": "string",
1929
+ "title": "Identity Number"
1930
+ },
1931
+ "is_active": {
1932
+ "type": "boolean",
1933
+ "title": "Is Active"
1934
+ },
1935
+ "is_superuser": {
1936
+ "type": "boolean",
1937
+ "title": "Is Superuser"
1938
+ },
1939
+ "id": {
1940
+ "type": "integer",
1941
+ "title": "Id"
1942
+ },
1943
+ "created_at": {
1944
+ "type": "string",
1945
+ "format": "date-time",
1946
+ "title": "Created At"
1947
+ },
1948
+ "updated_at": {
1949
+ "type": "string",
1950
+ "format": "date-time",
1951
+ "title": "Updated At"
1952
+ }
1953
+ },
1954
+ "type": "object",
1955
+ "title": "User"
1956
+ },
1957
+ "UserResponse": {
1958
+ "properties": {
1959
+ "id": {
1960
+ "type": "integer",
1961
+ "title": "Id"
1962
+ },
1963
+ "identity_number": {
1964
+ "type": "string",
1965
+ "title": "Identity Number"
1966
+ },
1967
+ "name": {
1968
+ "type": "string",
1969
+ "title": "Name"
1970
+ },
1971
+ "role": {
1972
+ "$ref": "#/components/schemas/app__schema__auth_schema__Role"
1973
+ },
1974
+ "email": {
1975
+ "type": "string",
1976
+ "title": "Email"
1977
+ },
1978
+ "status": {
1979
+ "$ref": "#/components/schemas/UserStatus"
1980
+ },
1981
+ "created_at": {
1982
+ "type": "string",
1983
+ "format": "date-time",
1984
+ "title": "Created At"
1985
+ },
1986
+ "updated_at": {
1987
+ "type": "string",
1988
+ "format": "date-time",
1989
+ "title": "Updated At"
1990
+ }
1991
+ },
1992
+ "type": "object",
1993
+ "required": [
1994
+ "id",
1995
+ "identity_number",
1996
+ "role",
1997
+ "email",
1998
+ "status",
1999
+ "created_at",
2000
+ "updated_at"
2001
+ ],
2002
+ "title": "UserResponse",
2003
+ "description": "Mirrors the OpenAPI `User` schema.\nMaps `is_active: bool` → `status: active|inactive` at serialization time.\n[ENGINEERING DECISION] DB column stays as `is_active` to avoid a migration\nbreaking change; the mapping is handled here in the Pydantic layer."
2004
+ },
2005
+ "UserStatus": {
2006
+ "type": "string",
2007
+ "enum": ["active", "inactive"],
2008
+ "title": "UserStatus",
2009
+ "description": "An enumeration."
2010
+ },
2011
+ "ValidationError": {
2012
+ "properties": {
2013
+ "loc": {
2014
+ "items": {
2015
+ "anyOf": [
2016
+ {
2017
+ "type": "string"
2018
+ },
2019
+ {
2020
+ "type": "integer"
2021
+ }
2022
+ ]
2023
+ },
2024
+ "type": "array",
2025
+ "title": "Location"
2026
+ },
2027
+ "msg": {
2028
+ "type": "string",
2029
+ "title": "Message"
2030
+ },
2031
+ "type": {
2032
+ "type": "string",
2033
+ "title": "Error Type"
2034
+ }
2035
+ },
2036
+ "type": "object",
2037
+ "required": ["loc", "msg", "type"],
2038
+ "title": "ValidationError"
2039
+ },
2040
+ "app__schema__auth_schema__Role": {
2041
+ "type": "string",
2042
+ "enum": ["admin", "student", "lecturer"],
2043
+ "title": "Role",
2044
+ "description": "An enumeration."
2045
+ },
2046
+ "app__schema__user_schema__Role": {
2047
+ "type": "string",
2048
+ "enum": ["student", "lecturer", "admin"],
2049
+ "title": "Role",
2050
+ "description": "An enumeration."
2051
+ }
2052
+ },
2053
+ "securitySchemes": {
2054
+ "JWTBearer": {
2055
+ "type": "http",
2056
+ "scheme": "bearer"
2057
+ }
2058
+ }
2059
+ }
2060
+ }
tests/Feature/Auth/RegistrationTest.php CHANGED
@@ -1,5 +1,6 @@
1
  <?php
2
 
 
3
  use Laravel\Fortify\Features;
4
 
5
  beforeEach(function () {
@@ -10,8 +11,23 @@
10
  $response = $this->get(route('register'));
11
 
12
  $response->assertOk();
 
 
 
 
13
  });
14
 
 
 
 
 
 
 
 
 
 
 
 
15
  test('new users can register', function () {
16
  $response = $this->post(route('register.store'), [
17
  'name' => 'Test User',
@@ -22,4 +38,4 @@
22
 
23
  $this->assertAuthenticated();
24
  $response->assertRedirect(route('dashboard', absolute: false));
25
- });
 
1
  <?php
2
 
3
+ use Inertia\Testing\AssertableInertia as Assert;
4
  use Laravel\Fortify\Features;
5
 
6
  beforeEach(function () {
 
11
  $response = $this->get(route('register'));
12
 
13
  $response->assertOk();
14
+
15
+ $response->assertInertia(fn (Assert $page) => $page
16
+ ->component('auth/register-choice'),
17
+ );
18
  });
19
 
20
+ test('role registration screens can be rendered', function (string $routeName, string $component) {
21
+ $this->get(route($routeName))
22
+ ->assertOk()
23
+ ->assertInertia(fn (Assert $page) => $page
24
+ ->component($component),
25
+ );
26
+ })->with([
27
+ 'mahasiswa' => ['register.mahasiswa', 'auth/register-mahasiswa'],
28
+ 'dosen' => ['register.dosen', 'auth/register-dosen'],
29
+ ]);
30
+
31
  test('new users can register', function () {
32
  $response = $this->post(route('register.store'), [
33
  'name' => 'Test User',
 
38
 
39
  $this->assertAuthenticated();
40
  $response->assertRedirect(route('dashboard', absolute: false));
41
+ });
vite.config.ts CHANGED
@@ -12,8 +12,8 @@ export default defineConfig({
12
  input: ['resources/css/app.css', 'resources/js/app.tsx'],
13
  refresh: true,
14
  fonts: [
15
- bunny('Instrument Sans', {
16
- weights: [400, 500, 600],
17
  }),
18
  ],
19
  }),
 
12
  input: ['resources/css/app.css', 'resources/js/app.tsx'],
13
  refresh: true,
14
  fonts: [
15
+ bunny('Inter', {
16
+ weights: [400, 500, 600, 700],
17
  }),
18
  ],
19
  }),