wepee commited on
Commit
f7d6ffd
·
1 Parent(s): 288c05b

add: iframe added

Browse files
.env.example CHANGED
@@ -33,6 +33,10 @@ SESSION_LIFETIME=120
33
  SESSION_ENCRYPT=false
34
  SESSION_PATH=/
35
  SESSION_DOMAIN=null
 
 
 
 
36
 
37
  BROADCAST_CONNECTION=log
38
  FILESYSTEM_DISK=local
 
33
  SESSION_ENCRYPT=false
34
  SESSION_PATH=/
35
  SESSION_DOMAIN=null
36
+ # Required when /embed is served inside a cross-site HTTPS iframe.
37
+ # SESSION_SECURE_COOKIE=true
38
+ # SESSION_SAME_SITE=none
39
+ # SESSION_PARTITIONED_COOKIE=true
40
 
41
  BROADCAST_CONNECTION=log
42
  FILESYSTEM_DISK=local
app/Http/Controllers/AdminController.php ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ namespace App\Http\Controllers;
4
+
5
+ use Inertia\Inertia;
6
+ use Inertia\Response;
7
+
8
+ class AdminController extends Controller
9
+ {
10
+ public function index(): Response
11
+ {
12
+ return Inertia::render('admin');
13
+ }
14
+
15
+ public function settings(): Response
16
+ {
17
+ return Inertia::render('admin/pengaturan');
18
+ }
19
+ }
app/Http/Controllers/AuthController.php CHANGED
@@ -6,6 +6,8 @@
6
  use App\Services\RagApiClient;
7
  use Illuminate\Http\RedirectResponse;
8
  use Illuminate\Http\Request;
 
 
9
  use Symfony\Component\HttpFoundation\Cookie;
10
 
11
  class AuthController extends Controller
@@ -14,7 +16,24 @@ class AuthController extends Controller
14
 
15
  private const AUTH_USER_COOKIE = 'sevima_raghub_auth_user';
16
 
 
 
 
 
 
 
 
17
  public function login(Request $request, RagApiClient $rag): RedirectResponse
 
 
 
 
 
 
 
 
 
 
18
  {
19
  /** @var array{email: string, password: string, remember?: bool} $payload */
20
  $payload = $request->validate([
@@ -36,8 +55,15 @@ public function login(Request $request, RagApiClient $rag): RedirectResponse
36
 
37
  $remember = (bool) ($payload['remember'] ?? false);
38
  $user = $response['user'];
 
 
 
 
 
 
 
39
 
40
- return redirect($this->dashboardPath($user['role'] ?? null))
41
  ->withCookie($this->authCookie($request, self::AUTH_TOKEN_COOKIE, $response['token'], $remember))
42
  ->withCookie($this->authCookie($request, self::AUTH_USER_COOKIE, json_encode($user, JSON_THROW_ON_ERROR), $remember));
43
  }
@@ -61,12 +87,13 @@ public function register(Request $request, RagApiClient $rag): RedirectResponse
61
  ->withInput($request->except('password'));
62
  }
63
 
64
- return back()->with('status', $response['message']);
65
  }
66
 
67
  private function dashboardPath(mixed $role): string
68
  {
69
  return match ($role) {
 
70
  'student' => route('mahasiswa', absolute: false),
71
  'lecturer' => route('dosen', absolute: false),
72
  default => route('dashboard', absolute: false),
 
6
  use App\Services\RagApiClient;
7
  use Illuminate\Http\RedirectResponse;
8
  use Illuminate\Http\Request;
9
+ use Inertia\Inertia;
10
+ use Inertia\Response;
11
  use Symfony\Component\HttpFoundation\Cookie;
12
 
13
  class AuthController extends Controller
 
16
 
17
  private const AUTH_USER_COOKIE = 'sevima_raghub_auth_user';
18
 
19
+ public function showAdminLogin(Request $request): Response
20
+ {
21
+ return Inertia::render('auth/admin-login', [
22
+ 'status' => $request->session()->get('status'),
23
+ ]);
24
+ }
25
+
26
  public function login(Request $request, RagApiClient $rag): RedirectResponse
27
+ {
28
+ return $this->attemptLogin($request, $rag);
29
+ }
30
+
31
+ public function adminLogin(Request $request, RagApiClient $rag): RedirectResponse
32
+ {
33
+ return $this->attemptLogin($request, $rag, 'admin');
34
+ }
35
+
36
+ private function attemptLogin(Request $request, RagApiClient $rag, ?string $requiredRole = null): RedirectResponse
37
  {
38
  /** @var array{email: string, password: string, remember?: bool} $payload */
39
  $payload = $request->validate([
 
55
 
56
  $remember = (bool) ($payload['remember'] ?? false);
57
  $user = $response['user'];
58
+ $role = $user['role'] ?? null;
59
+
60
+ if ($requiredRole !== null && $role !== $requiredRole) {
61
+ return back()
62
+ ->withErrors(['auth' => 'Akun ini tidak memiliki akses admin.'])
63
+ ->withInput($request->except('password'));
64
+ }
65
 
66
+ return redirect($this->dashboardPath($role))
67
  ->withCookie($this->authCookie($request, self::AUTH_TOKEN_COOKIE, $response['token'], $remember))
68
  ->withCookie($this->authCookie($request, self::AUTH_USER_COOKIE, json_encode($user, JSON_THROW_ON_ERROR), $remember));
69
  }
 
87
  ->withInput($request->except('password'));
88
  }
89
 
90
+ return to_route('login')->with('status', $response['message']);
91
  }
92
 
93
  private function dashboardPath(mixed $role): string
94
  {
95
  return match ($role) {
96
+ 'admin' => route('admin.dashboard', absolute: false),
97
  'student' => route('mahasiswa', absolute: false),
98
  'lecturer' => route('dosen', absolute: false),
99
  default => route('dashboard', absolute: false),
app/Http/Controllers/DosenController.php CHANGED
@@ -21,7 +21,7 @@ public function index(Request $request, RagApiClient $rag): Response
21
  $token = $this->authToken($request);
22
 
23
  try {
24
- $courses = $rag->listCourses(limit: 100)['data'];
25
 
26
  foreach ($courses as $course) {
27
  $documents = [];
 
21
  $token = $this->authToken($request);
22
 
23
  try {
24
+ $courses = $rag->listCourses(limit: 100, token: $token)['data'];
25
 
26
  foreach ($courses as $course) {
27
  $documents = [];
app/Http/Controllers/EmbedController.php ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ namespace App\Http\Controllers;
4
+
5
+ use App\Exceptions\RagApiException;
6
+ use App\Services\RagApiClient;
7
+ use Illuminate\Http\RedirectResponse;
8
+ use Illuminate\Http\Request;
9
+ use Illuminate\Support\Str;
10
+ use Inertia\Inertia;
11
+ use Inertia\Response;
12
+ use Symfony\Component\HttpFoundation\Cookie;
13
+
14
+ class EmbedController extends Controller
15
+ {
16
+ private const EMBED_TOKEN_COOKIE = 'sevima_raghub_embed_token';
17
+
18
+ private const EMBED_USER_COOKIE = 'sevima_raghub_embed_user';
19
+
20
+ private const DIRECT_MESSAGES_SESSION_PREFIX = 'sevima_raghub_embed_direct_messages';
21
+
22
+ private const PENDING_INITIAL_CHAT_SESSION_PREFIX = 'sevima_raghub_embed_pending_initial_chat';
23
+
24
+ /**
25
+ * @var array<int, string>
26
+ */
27
+ private const EMBED_QUERY_KEYS = [
28
+ 'background',
29
+ 'border',
30
+ 'course_id',
31
+ 'courseId',
32
+ 'danger',
33
+ 'muted',
34
+ 'primary',
35
+ 'primary_soft',
36
+ 'radius',
37
+ 'subtitle',
38
+ 'surface',
39
+ 'text',
40
+ 'title',
41
+ 'user_name',
42
+ ];
43
+
44
+ public function index(Request $request, RagApiClient $rag): Response|RedirectResponse
45
+ {
46
+ if ($request->filled('api_key')) {
47
+ return $this->bootstrapTokenFromApiKey($request, $rag);
48
+ }
49
+
50
+ if ($request->filled('token')) {
51
+ return $this->bootstrapTokenFromQuery($request);
52
+ }
53
+
54
+ $config = $this->embedConfig($request);
55
+ $token = $this->embedToken($request);
56
+
57
+ if ($token === null) {
58
+ return Inertia::render('embed', [
59
+ ...$this->emptyPayload($config),
60
+ 'backendMessage' => 'Token iframe tidak tersedia.',
61
+ 'isAuthorized' => false,
62
+ ]);
63
+ }
64
+
65
+ $backendMessage = null;
66
+ $chatSessions = [];
67
+ $courses = [];
68
+ $currentSession = null;
69
+ $messages = [];
70
+ $selectedCourseId = $config['courseId'];
71
+ $sessionId = $this->stringQuery($request, 'session_id');
72
+
73
+ try {
74
+ $courses = $rag->listCourses(limit: 100, token: $token)['data'];
75
+ $selectedCourseId = $this->selectedCourseId($selectedCourseId, $courses);
76
+ $chatSessions = $rag->listChatSessions(limit: 50, token: $token, courseId: $selectedCourseId)['data'];
77
+
78
+ if ($sessionId !== null) {
79
+ $currentSession = $rag->getChatSession($sessionId, $token);
80
+ $messages = $rag->getChatHistory($sessionId, $token)['data'];
81
+
82
+ if ($rag->isDirectChatMode()) {
83
+ $messages = [
84
+ ...$messages,
85
+ ...$this->directChatMessages($request, $sessionId),
86
+ ];
87
+ }
88
+
89
+ if (is_string($currentSession['course_id'] ?? null)) {
90
+ $selectedCourseId = $currentSession['course_id'];
91
+ }
92
+ }
93
+ } catch (RagApiException $exception) {
94
+ $backendMessage = $exception->getMessage() ?: null;
95
+ }
96
+
97
+ return Inertia::render('embed', [
98
+ 'backendMessage' => $backendMessage,
99
+ 'chatSessions' => $chatSessions,
100
+ 'config' => [
101
+ ...$config,
102
+ 'courseId' => $selectedCourseId,
103
+ ],
104
+ 'courses' => $courses,
105
+ 'currentSession' => $currentSession,
106
+ 'isAuthorized' => $backendMessage === null,
107
+ 'isDirectChatMode' => $rag->isDirectChatMode(),
108
+ 'messages' => $messages,
109
+ 'pendingInitialChat' => $sessionId === null
110
+ ? null
111
+ : $request->session()->pull($this->pendingInitialChatKey($sessionId)),
112
+ 'selectedCourseId' => $selectedCourseId,
113
+ 'sessionId' => $sessionId,
114
+ ]);
115
+ }
116
+
117
+ public function storeSession(Request $request, RagApiClient $rag): RedirectResponse
118
+ {
119
+ /** @var array{course_id: string, title: string} $payload */
120
+ $payload = $request->validate([
121
+ 'course_id' => ['required', 'string'],
122
+ 'title' => ['required', 'string'],
123
+ ]);
124
+
125
+ try {
126
+ $session = $rag->createChatSession($payload, $this->requireEmbedToken($request));
127
+ } catch (RagApiException $exception) {
128
+ return back()
129
+ ->withErrors(['chat' => $exception->getMessage()])
130
+ ->withInput();
131
+ }
132
+
133
+ $sessionId = (string) ($session['uuid_id'] ?? '');
134
+
135
+ $request->session()->flash($this->pendingInitialChatKey($sessionId), [
136
+ 'content' => $payload['title'],
137
+ 'courseId' => $payload['course_id'],
138
+ ]);
139
+
140
+ return redirect($this->embedRoute($request, ['session_id' => $sessionId]));
141
+ }
142
+
143
+ public function storeMessage(Request $request, RagApiClient $rag, string $sessionId): RedirectResponse
144
+ {
145
+ /** @var array{content: string, course_id: string} $payload */
146
+ $payload = $request->validate([
147
+ 'content' => ['required', 'string'],
148
+ 'course_id' => ['required', 'string'],
149
+ ]);
150
+ $userMessage = $this->localUserMessage($payload['content']);
151
+
152
+ try {
153
+ $assistantMessage = $rag->sendConfiguredChatMessage(
154
+ $sessionId,
155
+ $payload['course_id'],
156
+ $payload['content'],
157
+ $this->requireEmbedToken($request),
158
+ );
159
+ } catch (RagApiException $exception) {
160
+ return back()
161
+ ->withErrors(['message' => $exception->getMessage()])
162
+ ->withInput();
163
+ }
164
+
165
+ if ($rag->isDirectChatMode()) {
166
+ $this->appendDirectChatMessages($request, $sessionId, [
167
+ $userMessage,
168
+ $assistantMessage,
169
+ ]);
170
+ }
171
+
172
+ return redirect($this->embedRoute($request, ['session_id' => $sessionId]));
173
+ }
174
+
175
+ public function destroySession(Request $request, RagApiClient $rag, string $sessionId): RedirectResponse
176
+ {
177
+ try {
178
+ $rag->deleteChatSession($sessionId, $this->requireEmbedToken($request));
179
+ } catch (RagApiException $exception) {
180
+ return back()->withErrors(['session' => $exception->getMessage()]);
181
+ }
182
+
183
+ $this->clearDirectChatMessages($request, $sessionId);
184
+
185
+ return redirect($this->embedRoute($request, ['session_id' => null]));
186
+ }
187
+
188
+ private function bootstrapTokenFromApiKey(Request $request, RagApiClient $rag): Response|RedirectResponse
189
+ {
190
+ $apiKey = $this->stringQuery($request, 'api_key');
191
+
192
+ if ($apiKey === null) {
193
+ return redirect($this->embedRoute($request));
194
+ }
195
+
196
+ try {
197
+ $response = $rag->loginWithApiKey(['api_key' => $apiKey]);
198
+ } catch (RagApiException $exception) {
199
+ return Inertia::render('embed', [
200
+ ...$this->emptyPayload($this->embedConfig($request)),
201
+ 'backendMessage' => $exception->getMessage(),
202
+ 'isAuthorized' => false,
203
+ ]);
204
+ }
205
+
206
+ return redirect($this->embedRoute($request))
207
+ ->withCookie($this->embedCookie($request, self::EMBED_TOKEN_COOKIE, $response['token']))
208
+ ->withCookie($this->embedCookie($request, self::EMBED_USER_COOKIE, json_encode($response['user'], JSON_THROW_ON_ERROR)));
209
+ }
210
+
211
+ private function bootstrapTokenFromQuery(Request $request): RedirectResponse
212
+ {
213
+ $token = $this->stringQuery($request, 'token');
214
+
215
+ if ($token === null) {
216
+ return redirect($this->embedRoute($request));
217
+ }
218
+
219
+ return redirect($this->embedRoute($request))
220
+ ->withCookie($this->embedCookie($request, self::EMBED_TOKEN_COOKIE, $token));
221
+ }
222
+
223
+ /**
224
+ * @param array<string, mixed> $config
225
+ * @return array<string, mixed>
226
+ */
227
+ private function emptyPayload(array $config): array
228
+ {
229
+ return [
230
+ 'chatSessions' => [],
231
+ 'config' => $config,
232
+ 'courses' => [],
233
+ 'currentSession' => null,
234
+ 'isDirectChatMode' => false,
235
+ 'messages' => [],
236
+ 'pendingInitialChat' => null,
237
+ 'selectedCourseId' => $config['courseId'],
238
+ 'sessionId' => null,
239
+ ];
240
+ }
241
+
242
+ /**
243
+ * @return array<string, mixed>
244
+ */
245
+ private function embedConfig(Request $request): array
246
+ {
247
+ return [
248
+ 'background' => $this->colorQuery($request, 'background', '#ffffff'),
249
+ 'border' => $this->colorQuery($request, 'border', '#739af1'),
250
+ 'courseId' => $this->stringQuery($request, 'course_id') ?? $this->stringQuery($request, 'courseId'),
251
+ 'danger' => $this->colorQuery($request, 'danger', '#ef4444'),
252
+ 'muted' => $this->colorQuery($request, 'muted', '#737373'),
253
+ 'primary' => $this->colorQuery($request, 'primary', '#2764eb'),
254
+ 'primarySoft' => $this->colorQuery($request, 'primary_soft', '#ccdcff'),
255
+ 'radius' => $this->radiusQuery($request),
256
+ 'subtitle' => $this->stringQuery($request, 'subtitle') ?? 'Pilih mata kuliah, lalu kirim pertanyaan pertama untuk membuat sesi baru',
257
+ 'surface' => $this->colorQuery($request, 'surface', '#ffffff'),
258
+ 'text' => $this->colorQuery($request, 'text', '#0a0a0a'),
259
+ 'title' => $this->stringQuery($request, 'title') ?? 'RAG Hub',
260
+ 'userName' => $this->stringQuery($request, 'user_name') ?? $this->embedUserName($request),
261
+ ];
262
+ }
263
+
264
+ private function embedToken(Request $request): ?string
265
+ {
266
+ $token = $request->cookie(self::EMBED_TOKEN_COOKIE);
267
+
268
+ return is_string($token) && trim($token) !== '' ? $token : null;
269
+ }
270
+
271
+ private function requireEmbedToken(Request $request): string
272
+ {
273
+ $token = $this->embedToken($request);
274
+
275
+ if ($token === null) {
276
+ abort(401, 'Token iframe tidak tersedia.');
277
+ }
278
+
279
+ return $token;
280
+ }
281
+
282
+ private function embedUserName(Request $request): string
283
+ {
284
+ $rawUser = $request->cookie(self::EMBED_USER_COOKIE);
285
+
286
+ if (! is_string($rawUser) || $rawUser === '') {
287
+ return 'User';
288
+ }
289
+
290
+ $user = json_decode($rawUser, true);
291
+
292
+ return is_array($user) && is_string($user['name'] ?? null)
293
+ ? $user['name']
294
+ : 'User';
295
+ }
296
+
297
+ private function embedCookie(Request $request, string $name, string $value): Cookie
298
+ {
299
+ return cookie(
300
+ name: $name,
301
+ value: $value,
302
+ minutes: 60 * 24,
303
+ path: '/',
304
+ domain: null,
305
+ secure: $request->isSecure(),
306
+ httpOnly: true,
307
+ raw: false,
308
+ sameSite: $request->isSecure() ? 'none' : 'lax',
309
+ );
310
+ }
311
+
312
+ /**
313
+ * @param array<int, array<string, mixed>> $courses
314
+ */
315
+ private function selectedCourseId(?string $configuredCourseId, array $courses): ?string
316
+ {
317
+ if ($configuredCourseId !== null && $configuredCourseId !== '') {
318
+ return $configuredCourseId;
319
+ }
320
+
321
+ $courseId = $courses[0]['id'] ?? null;
322
+
323
+ return $courseId === null ? null : (string) $courseId;
324
+ }
325
+
326
+ /**
327
+ * @param array<string, string|null> $merge
328
+ */
329
+ private function embedRoute(Request $request, array $merge = []): string
330
+ {
331
+ $query = [];
332
+
333
+ foreach (self::EMBED_QUERY_KEYS as $key) {
334
+ $value = $request->query($key);
335
+
336
+ if (is_string($value) && $value !== '') {
337
+ $query[$key] = $value;
338
+ }
339
+ }
340
+
341
+ foreach ($merge as $key => $value) {
342
+ if ($value === null || $value === '') {
343
+ unset($query[$key]);
344
+
345
+ continue;
346
+ }
347
+
348
+ $query[$key] = $value;
349
+ }
350
+
351
+ return route('embed', $query, false);
352
+ }
353
+
354
+ private function stringQuery(Request $request, string $key): ?string
355
+ {
356
+ $value = $request->query($key);
357
+
358
+ return is_string($value) && trim($value) !== '' ? trim($value) : null;
359
+ }
360
+
361
+ private function colorQuery(Request $request, string $key, string $fallback): string
362
+ {
363
+ $value = $this->stringQuery($request, $key);
364
+
365
+ if ($value === null) {
366
+ return $fallback;
367
+ }
368
+
369
+ $normalizedValue = str_starts_with($value, '#') ? $value : '#'.$value;
370
+
371
+ return preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $normalizedValue) === 1
372
+ ? $normalizedValue
373
+ : $fallback;
374
+ }
375
+
376
+ private function radiusQuery(Request $request): string
377
+ {
378
+ $value = $this->stringQuery($request, 'radius');
379
+
380
+ if ($value === null) {
381
+ return '20px';
382
+ }
383
+
384
+ return preg_match('/^\d{1,2}(?:px|rem)$/', $value) === 1
385
+ ? $value
386
+ : '20px';
387
+ }
388
+
389
+ /**
390
+ * @return array{uuid_id: string, role: string, content: string, sources: array<int, mixed>, created_at: string}
391
+ */
392
+ private function localUserMessage(string $content): array
393
+ {
394
+ return [
395
+ 'content' => $content,
396
+ 'created_at' => now()->toISOString(),
397
+ 'role' => 'user',
398
+ 'sources' => [],
399
+ 'uuid_id' => 'local-'.Str::uuid(),
400
+ ];
401
+ }
402
+
403
+ /**
404
+ * @return array<int, array<string, mixed>>
405
+ */
406
+ private function directChatMessages(Request $request, string $sessionId): array
407
+ {
408
+ $messages = $request->session()->get($this->directMessagesKey($sessionId), []);
409
+
410
+ return is_array($messages) ? $messages : [];
411
+ }
412
+
413
+ /**
414
+ * @param array<int, array<string, mixed>> $messages
415
+ */
416
+ private function appendDirectChatMessages(Request $request, string $sessionId, array $messages): void
417
+ {
418
+ $request->session()->put($this->directMessagesKey($sessionId), [
419
+ ...$this->directChatMessages($request, $sessionId),
420
+ ...$messages,
421
+ ]);
422
+ }
423
+
424
+ private function clearDirectChatMessages(Request $request, string $sessionId): void
425
+ {
426
+ $request->session()->forget($this->directMessagesKey($sessionId));
427
+ }
428
+
429
+ private function directMessagesKey(string $sessionId): string
430
+ {
431
+ return self::DIRECT_MESSAGES_SESSION_PREFIX.'.'.$sessionId;
432
+ }
433
+
434
+ private function pendingInitialChatKey(string $sessionId): string
435
+ {
436
+ return self::PENDING_INITIAL_CHAT_SESSION_PREFIX.'.'.$sessionId;
437
+ }
438
+ }
app/Http/Controllers/MahasiswaController.php CHANGED
@@ -85,6 +85,7 @@ public function show(Request $request, RagApiClient $rag, string $sessionId): Re
85
  ...$payload,
86
  'backendMessage' => $backendMessage,
87
  'currentSession' => $currentSession,
 
88
  'messages' => $messages,
89
  'pendingInitialChat' => $request->session()->pull($this->pendingInitialChatKey($sessionId)),
90
  'selectedCourseId' => is_string($currentSession['course_id'] ?? null)
@@ -154,7 +155,7 @@ private function studentDashboardPayload(Request $request, RagApiClient $rag): a
154
  $token = $this->authToken($request);
155
 
156
  try {
157
- $courses = $rag->listCourses(limit: 100)['data'];
158
  } catch (RagApiException $exception) {
159
  $backendMessage = $exception->getMessage() ?: null;
160
  }
 
85
  ...$payload,
86
  'backendMessage' => $backendMessage,
87
  'currentSession' => $currentSession,
88
+ 'isDirectChatMode' => $rag->isDirectChatMode(),
89
  'messages' => $messages,
90
  'pendingInitialChat' => $request->session()->pull($this->pendingInitialChatKey($sessionId)),
91
  'selectedCourseId' => is_string($currentSession['course_id'] ?? null)
 
155
  $token = $this->authToken($request);
156
 
157
  try {
158
+ $courses = $rag->listCourses(limit: 100, token: $token)['data'];
159
  } catch (RagApiException $exception) {
160
  $backendMessage = $exception->getMessage() ?: null;
161
  }
app/Http/Middleware/EnsureRagAuthenticated.php ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ namespace App\Http\Middleware;
4
+
5
+ use Closure;
6
+ use Illuminate\Http\RedirectResponse;
7
+ use Illuminate\Http\Request;
8
+ use Symfony\Component\HttpFoundation\Response;
9
+
10
+ class EnsureRagAuthenticated
11
+ {
12
+ private const AUTH_TOKEN_COOKIE = 'sevima_raghub_auth_token';
13
+
14
+ private const AUTH_USER_COOKIE = 'sevima_raghub_auth_user';
15
+
16
+ /**
17
+ * Handle an incoming request.
18
+ *
19
+ * @param Closure(Request): (Response) $next
20
+ */
21
+ public function handle(Request $request, Closure $next, string ...$roles): Response
22
+ {
23
+ $token = $request->cookie(self::AUTH_TOKEN_COOKIE);
24
+
25
+ if (! is_string($token) || trim($token) === '') {
26
+ return $this->redirectToLogin($roles);
27
+ }
28
+
29
+ if ($roles !== []) {
30
+ $user = $this->userFromCookie($request);
31
+ $role = $user['role'] ?? null;
32
+
33
+ if (! is_string($role) || ! in_array($role, $roles, true)) {
34
+ return is_string($role)
35
+ ? redirect($this->dashboardPath($role))
36
+ ->with('status', 'Akses tidak sesuai role akun.')
37
+ : $this->redirectToLogin($roles);
38
+ }
39
+ }
40
+
41
+ return $next($request);
42
+ }
43
+
44
+ /**
45
+ * @param array<int, string> $roles
46
+ */
47
+ private function redirectToLogin(array $roles = []): RedirectResponse
48
+ {
49
+ $route = in_array('admin', $roles, true) ? 'admin.login' : 'login';
50
+
51
+ return redirect()->route($route)
52
+ ->with('status', 'Silakan login terlebih dahulu.');
53
+ }
54
+
55
+ /**
56
+ * @return array<string, mixed>
57
+ */
58
+ private function userFromCookie(Request $request): array
59
+ {
60
+ $rawUser = $request->cookie(self::AUTH_USER_COOKIE);
61
+
62
+ if (! is_string($rawUser) || $rawUser === '') {
63
+ return [];
64
+ }
65
+
66
+ $user = json_decode($rawUser, true);
67
+
68
+ return is_array($user) ? $user : [];
69
+ }
70
+
71
+ private function dashboardPath(string $role): string
72
+ {
73
+ return match ($role) {
74
+ 'admin' => route('admin.dashboard', absolute: false),
75
+ 'student' => route('mahasiswa', absolute: false),
76
+ 'lecturer' => route('dosen', absolute: false),
77
+ default => route('dashboard', absolute: false),
78
+ };
79
+ }
80
+ }
app/Http/Middleware/HandleInertiaRequests.php CHANGED
@@ -43,6 +43,7 @@ public function share(Request $request): array
43
  ],
44
  'flash' => [
45
  'status' => $request->session()->get('status'),
 
46
  ],
47
  'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
48
  ];
 
43
  ],
44
  'flash' => [
45
  'status' => $request->session()->get('status'),
46
+ 'toast' => $request->session()->get('toast'),
47
  ],
48
  'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
49
  ];
app/Services/RagApiClient.php CHANGED
@@ -26,6 +26,22 @@ public function login(array $payload): array
26
  return $response->json();
27
  }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  /**
30
  * @param array{email: string, password: string, name: string, role: string, identity_number: string} $payload
31
  * @return array{message: string, user: array<string, mixed>}
@@ -45,9 +61,9 @@ public function register(array $payload): array
45
  /**
46
  * @return array{data: array<int, array{id: int|string, title: string, description?: string|null}>, total?: int, page?: int, limit?: int}
47
  */
48
- public function listCourses(int $page = 1, int $limit = 100): array
49
  {
50
- $response = $this->request()->get('/courses', [
51
  'limit' => $limit,
52
  'page' => $page,
53
  ]);
 
26
  return $response->json();
27
  }
28
 
29
+ /**
30
+ * @param array{api_key: string} $payload
31
+ * @return array{token: string, user: array<string, mixed>}
32
+ */
33
+ public function loginWithApiKey(array $payload): array
34
+ {
35
+ $response = $this->request()->post('/auth/login/api-key', $payload);
36
+
37
+ if ($response->failed()) {
38
+ throw RagApiException::fromResponse($response);
39
+ }
40
+
41
+ /** @var array{token: string, user: array<string, mixed>} */
42
+ return $response->json();
43
+ }
44
+
45
  /**
46
  * @param array{email: string, password: string, name: string, role: string, identity_number: string} $payload
47
  * @return array{message: string, user: array<string, mixed>}
 
61
  /**
62
  * @return array{data: array<int, array{id: int|string, title: string, description?: string|null}>, total?: int, page?: int, limit?: int}
63
  */
64
+ public function listCourses(int $page = 1, int $limit = 100, ?string $token = null): array
65
  {
66
+ $response = $this->request($token)->get('/courses', [
67
  'limit' => $limit,
68
  'page' => $page,
69
  ]);
bootstrap/app.php CHANGED
@@ -1,5 +1,6 @@
1
  <?php
2
 
 
3
  use App\Http\Middleware\HandleAppearance;
4
  use App\Http\Middleware\HandleInertiaRequests;
5
  use Illuminate\Foundation\Application;
@@ -16,10 +17,16 @@
16
  ->withMiddleware(function (Middleware $middleware): void {
17
  $middleware->trustProxies(at: '*');
18
 
 
 
 
 
19
  $middleware->encryptCookies(except: [
20
  'appearance',
21
  'sevima_raghub_auth_token',
22
  'sevima_raghub_auth_user',
 
 
23
  'sidebar_state',
24
  ]);
25
 
 
1
  <?php
2
 
3
+ use App\Http\Middleware\EnsureRagAuthenticated;
4
  use App\Http\Middleware\HandleAppearance;
5
  use App\Http\Middleware\HandleInertiaRequests;
6
  use Illuminate\Foundation\Application;
 
17
  ->withMiddleware(function (Middleware $middleware): void {
18
  $middleware->trustProxies(at: '*');
19
 
20
+ $middleware->alias([
21
+ 'rag.auth' => EnsureRagAuthenticated::class,
22
+ ]);
23
+
24
  $middleware->encryptCookies(except: [
25
  'appearance',
26
  'sevima_raghub_auth_token',
27
  'sevima_raghub_auth_user',
28
+ 'sevima_raghub_embed_token',
29
+ 'sevima_raghub_embed_user',
30
  'sidebar_state',
31
  ]);
32
 
resources/css/app.css CHANGED
@@ -109,7 +109,7 @@ :root {
109
  --app-primary-soft: #ccdcff;
110
  --app-primary-foreground: #ffffff;
111
  --app-danger: #ef4444;
112
- --app-success: #16a34a;
113
  --app-placeholder: rgba(115, 115, 115, 0.42);
114
  --app-toggle-bg: rgba(255, 255, 255, 0.9);
115
  --app-shadow: 1px 1px 4.5px rgba(39, 100, 235, 0.36);
@@ -158,6 +158,7 @@ :root {
158
  --student-primary-soft: var(--app-primary-soft);
159
  --student-primary-foreground: var(--app-primary-foreground);
160
  --student-danger: var(--app-danger);
 
161
  --student-placeholder: var(--app-placeholder);
162
  --student-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
163
  --student-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
@@ -228,7 +229,7 @@ .dark {
228
  --app-primary-soft: #c8d7ff;
229
  --app-primary-foreground: #ffffff;
230
  --app-danger: #ff4545;
231
- --app-success: #22c55e;
232
  --app-placeholder: rgba(244, 244, 245, 0.42);
233
  --app-toggle-bg: #171717;
234
  --app-shadow: 0 0 20px rgba(52, 120, 255, 0.34);
@@ -273,6 +274,7 @@ .dark {
273
  --student-primary-soft: var(--app-primary-soft);
274
  --student-primary-foreground: var(--app-primary-foreground);
275
  --student-danger: var(--app-danger);
 
276
  --student-placeholder: var(--app-placeholder);
277
  --student-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
278
  --student-sidebar-shadow: 1px 0 0 var(--app-border);
@@ -622,7 +624,7 @@ @layer components {
622
 
623
  .login-status {
624
  @apply mt-4 text-center text-sm font-medium;
625
- color: var(--welcome-accent);
626
  }
627
 
628
  .login-error {
@@ -738,7 +740,7 @@ @layer components {
738
  color: var(--student-muted);
739
  height: auto !important;
740
  min-height: 42px !important;
741
- padding: 8px 4px !important;
742
  align-items: flex-start !important;
743
  }
744
 
@@ -1022,6 +1024,10 @@ @layer components {
1022
  color: var(--student-danger);
1023
  }
1024
 
 
 
 
 
1025
  .student-course-row {
1026
  @apply flex flex-wrap items-center gap-2 text-[11px] font-medium;
1027
  color: var(--student-text);
@@ -1136,6 +1142,11 @@ @layer components {
1136
  @apply leading-relaxed whitespace-pre-wrap;
1137
  }
1138
 
 
 
 
 
 
1139
  .student-message-time {
1140
  @apply mt-2 block text-[10px];
1141
  color: color-mix(in oklab, currentColor 72%, transparent);
@@ -1192,6 +1203,277 @@ @layer components {
1192
  color: var(--student-muted);
1193
  }
1194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1195
  .lecturer-dashboard-shell {
1196
  @apply min-h-svh;
1197
  background-color: var(--lecturer-bg);
@@ -1329,6 +1611,32 @@ @layer components {
1329
  color: var(--lecturer-danger);
1330
  }
1331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1332
  .group[data-collapsible='icon'] .lecturer-sidebar-header {
1333
  justify-content: center !important;
1334
  padding: 0 !important;
@@ -1604,6 +1912,20 @@ @layer components {
1604
  color: var(--lecturer-danger);
1605
  }
1606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1607
  .lecturer-table-wrap {
1608
  @apply w-full overflow-x-auto;
1609
  }
 
109
  --app-primary-soft: #ccdcff;
110
  --app-primary-foreground: #ffffff;
111
  --app-danger: #ef4444;
112
+ --app-success: var(--app-primary);
113
  --app-placeholder: rgba(115, 115, 115, 0.42);
114
  --app-toggle-bg: rgba(255, 255, 255, 0.9);
115
  --app-shadow: 1px 1px 4.5px rgba(39, 100, 235, 0.36);
 
158
  --student-primary-soft: var(--app-primary-soft);
159
  --student-primary-foreground: var(--app-primary-foreground);
160
  --student-danger: var(--app-danger);
161
+ --student-success: var(--app-success);
162
  --student-placeholder: var(--app-placeholder);
163
  --student-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
164
  --student-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
 
229
  --app-primary-soft: #c8d7ff;
230
  --app-primary-foreground: #ffffff;
231
  --app-danger: #ff4545;
232
+ --app-success: var(--app-primary);
233
  --app-placeholder: rgba(244, 244, 245, 0.42);
234
  --app-toggle-bg: #171717;
235
  --app-shadow: 0 0 20px rgba(52, 120, 255, 0.34);
 
274
  --student-primary-soft: var(--app-primary-soft);
275
  --student-primary-foreground: var(--app-primary-foreground);
276
  --student-danger: var(--app-danger);
277
+ --student-success: var(--app-success);
278
  --student-placeholder: var(--app-placeholder);
279
  --student-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
280
  --student-sidebar-shadow: 1px 0 0 var(--app-border);
 
624
 
625
  .login-status {
626
  @apply mt-4 text-center text-sm font-medium;
627
+ color: var(--app-success);
628
  }
629
 
630
  .login-error {
 
740
  color: var(--student-muted);
741
  height: auto !important;
742
  min-height: 42px !important;
743
+ padding: 8px 34px 8px 4px !important;
744
  align-items: flex-start !important;
745
  }
746
 
 
1024
  color: var(--student-danger);
1025
  }
1026
 
1027
+ .student-status--success {
1028
+ color: var(--student-success);
1029
+ }
1030
+
1031
  .student-course-row {
1032
  @apply flex flex-wrap items-center gap-2 text-[11px] font-medium;
1033
  color: var(--student-text);
 
1142
  @apply leading-relaxed whitespace-pre-wrap;
1143
  }
1144
 
1145
+ .student-message-caret {
1146
+ @apply ml-0.5 inline-block h-3 w-px animate-pulse align-[-2px];
1147
+ background-color: currentColor;
1148
+ }
1149
+
1150
  .student-message-time {
1151
  @apply mt-2 block text-[10px];
1152
  color: color-mix(in oklab, currentColor 72%, transparent);
 
1203
  color: var(--student-muted);
1204
  }
1205
 
1206
+ .embed-shell {
1207
+ @apply relative flex min-h-svh flex-col overflow-hidden px-6 py-5;
1208
+ background-color: var(--embed-bg);
1209
+ color: var(--embed-text);
1210
+ }
1211
+
1212
+ .embed-toolbar {
1213
+ @apply relative z-20 flex shrink-0 items-start justify-between gap-4;
1214
+ }
1215
+
1216
+ .embed-tabs {
1217
+ @apply flex h-8 items-center gap-1 rounded-full p-1 shadow-sm;
1218
+ background-color: color-mix(
1219
+ in oklab,
1220
+ var(--embed-muted) 20%,
1221
+ transparent
1222
+ );
1223
+ }
1224
+
1225
+ .embed-tabs button {
1226
+ @apply h-6 rounded-full px-6 text-xs font-medium transition;
1227
+ color: var(--embed-text);
1228
+ }
1229
+
1230
+ .embed-tabs button[aria-selected='true'] {
1231
+ @apply shadow-sm;
1232
+ background-color: var(--embed-surface);
1233
+ }
1234
+
1235
+ .embed-actions {
1236
+ @apply flex items-center gap-5;
1237
+ }
1238
+
1239
+ .embed-icon-button {
1240
+ @apply flex size-8 items-center justify-center rounded-[5px] transition;
1241
+ color: var(--embed-text);
1242
+ }
1243
+
1244
+ .embed-icon-button:hover {
1245
+ background-color: color-mix(
1246
+ in oklab,
1247
+ var(--embed-muted) 14%,
1248
+ transparent
1249
+ );
1250
+ }
1251
+
1252
+ .embed-icon-button--primary {
1253
+ background-color: var(--embed-primary);
1254
+ color: #ffffff;
1255
+ }
1256
+
1257
+ .embed-icon-button--primary:hover {
1258
+ background-color: color-mix(
1259
+ in oklab,
1260
+ var(--embed-primary) 88%,
1261
+ #ffffff
1262
+ );
1263
+ }
1264
+
1265
+ .embed-chat {
1266
+ @apply relative flex min-h-0 flex-1 flex-col items-center justify-center;
1267
+ }
1268
+
1269
+ .embed-chat--session {
1270
+ @apply items-stretch justify-start;
1271
+ }
1272
+
1273
+ .embed-empty-state {
1274
+ @apply mx-auto flex w-full max-w-[620px] -translate-y-5 flex-col items-center text-center;
1275
+ }
1276
+
1277
+ .embed-empty-icon {
1278
+ @apply mb-5 flex size-[52px] items-center justify-center rounded-[14px];
1279
+ background-color: var(--embed-primary-soft);
1280
+ color: var(--embed-primary);
1281
+ }
1282
+
1283
+ .embed-empty-state h1 {
1284
+ @apply text-2xl leading-tight font-bold tracking-[-0.02em];
1285
+ color: var(--embed-text);
1286
+ }
1287
+
1288
+ .embed-empty-state p {
1289
+ @apply mt-2 text-xs;
1290
+ color: var(--embed-muted);
1291
+ }
1292
+
1293
+ .embed-status {
1294
+ @apply mt-4 text-center text-xs font-medium;
1295
+ color: var(--embed-danger);
1296
+ }
1297
+
1298
+ .embed-thread {
1299
+ @apply min-h-0 flex-1 space-y-5 overflow-y-auto px-0 pt-10;
1300
+ padding-bottom: 11rem;
1301
+ }
1302
+
1303
+ .embed-message {
1304
+ @apply flex w-full items-start gap-3;
1305
+ }
1306
+
1307
+ .embed-message--assistant {
1308
+ @apply justify-start;
1309
+ }
1310
+
1311
+ .embed-message--user {
1312
+ @apply justify-end;
1313
+ }
1314
+
1315
+ .embed-message-avatar {
1316
+ @apply flex size-8 shrink-0 items-center justify-center rounded-[9px];
1317
+ }
1318
+
1319
+ .embed-message-avatar--assistant {
1320
+ background-color: var(--embed-primary);
1321
+ color: #ffffff;
1322
+ }
1323
+
1324
+ .embed-message-avatar--user {
1325
+ background-color: var(--embed-primary-soft);
1326
+ color: var(--embed-primary);
1327
+ }
1328
+
1329
+ .embed-message-bubble {
1330
+ @apply max-w-[min(290px,75%)] rounded-[10px] border px-4 py-3 text-xs shadow-none;
1331
+ background-color: var(--embed-surface);
1332
+ border-color: var(--embed-border);
1333
+ color: var(--embed-text);
1334
+ }
1335
+
1336
+ .embed-message--assistant .embed-message-bubble {
1337
+ color: var(--embed-text);
1338
+ }
1339
+
1340
+ .embed-message--user .embed-message-bubble {
1341
+ background-color: var(--embed-primary);
1342
+ border-color: var(--embed-primary);
1343
+ color: #ffffff;
1344
+ }
1345
+
1346
+ .embed-message-content {
1347
+ @apply leading-relaxed whitespace-pre-wrap;
1348
+ }
1349
+
1350
+ .embed-message-time {
1351
+ @apply mt-2 block text-[10px];
1352
+ color: color-mix(in oklab, currentColor 68%, transparent);
1353
+ }
1354
+
1355
+ .embed-message-caret {
1356
+ @apply ml-0.5 inline-block h-3 w-px animate-pulse align-[-2px];
1357
+ background-color: currentColor;
1358
+ }
1359
+
1360
+ .embed-message-bubble--typing {
1361
+ color: var(--embed-muted);
1362
+ }
1363
+
1364
+ .embed-composer-dock {
1365
+ @apply absolute right-0 bottom-0 left-0 z-10;
1366
+ }
1367
+
1368
+ .embed-composer {
1369
+ @apply relative w-full rounded-[var(--embed-radius)] border px-4 py-3;
1370
+ -webkit-backdrop-filter: blur(16px) saturate(1.25);
1371
+ backdrop-filter: blur(16px) saturate(1.25);
1372
+ background-color: color-mix(
1373
+ in oklab,
1374
+ var(--embed-surface) 82%,
1375
+ transparent
1376
+ );
1377
+ border-color: var(--embed-border);
1378
+ box-shadow: 0 18px 40px color-mix(in oklab, var(--embed-primary) 24%, transparent);
1379
+ }
1380
+
1381
+ .embed-course-row {
1382
+ @apply flex flex-wrap items-center gap-2 text-[11px] font-bold;
1383
+ color: var(--embed-text);
1384
+ }
1385
+
1386
+ .embed-course-row svg,
1387
+ .embed-question-row svg {
1388
+ color: var(--embed-primary);
1389
+ }
1390
+
1391
+ .embed-course-row select {
1392
+ @apply h-6 max-w-[210px] rounded-[5px] border px-2 text-[10px] outline-none;
1393
+ background-color: var(--embed-surface);
1394
+ border-color: var(--embed-border);
1395
+ color: var(--embed-text);
1396
+ }
1397
+
1398
+ .embed-course-row select:disabled {
1399
+ @apply opacity-70;
1400
+ }
1401
+
1402
+ .embed-composer-divider {
1403
+ @apply my-3 h-px w-full;
1404
+ background-color: color-mix(
1405
+ in oklab,
1406
+ var(--embed-muted) 24%,
1407
+ transparent
1408
+ );
1409
+ }
1410
+
1411
+ .embed-question-row {
1412
+ @apply flex items-start gap-3;
1413
+ }
1414
+
1415
+ .embed-question-row textarea {
1416
+ @apply min-h-[72px] flex-1 resize-none bg-transparent text-xs outline-none;
1417
+ color: var(--embed-text);
1418
+ }
1419
+
1420
+ .embed-question-row textarea::placeholder {
1421
+ color: color-mix(in oklab, var(--embed-muted) 48%, transparent);
1422
+ }
1423
+
1424
+ .embed-send-button {
1425
+ @apply absolute right-4 bottom-3 flex size-8 items-center justify-center rounded-[8px] transition disabled:opacity-55;
1426
+ background-color: var(--embed-primary);
1427
+ color: #ffffff;
1428
+ }
1429
+
1430
+ .embed-updated-at {
1431
+ @apply mt-2 text-center text-[10px];
1432
+ color: var(--embed-muted);
1433
+ }
1434
+
1435
+ .embed-history {
1436
+ @apply flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto pt-7;
1437
+ }
1438
+
1439
+ .embed-history-item {
1440
+ @apply flex w-full items-center justify-between gap-4 rounded-[7px] px-6 py-3 text-left transition;
1441
+ color: var(--embed-text);
1442
+ }
1443
+
1444
+ .embed-history-item[data-active='true'],
1445
+ .embed-history-item:hover {
1446
+ background-color: color-mix(
1447
+ in oklab,
1448
+ var(--embed-muted) 18%,
1449
+ transparent
1450
+ );
1451
+ }
1452
+
1453
+ .embed-history-item strong {
1454
+ @apply block text-base font-semibold;
1455
+ }
1456
+
1457
+ .embed-history-item small {
1458
+ @apply mt-1 block text-xs;
1459
+ color: var(--embed-text);
1460
+ }
1461
+
1462
+ .embed-history-delete {
1463
+ @apply shrink-0 opacity-30 transition;
1464
+ color: var(--embed-danger);
1465
+ }
1466
+
1467
+ .embed-history-item[data-active='true'] .embed-history-delete,
1468
+ .embed-history-item:hover .embed-history-delete {
1469
+ @apply opacity-100;
1470
+ }
1471
+
1472
+ .embed-empty-copy {
1473
+ @apply mt-16 text-center text-sm;
1474
+ color: var(--embed-muted);
1475
+ }
1476
+
1477
  .lecturer-dashboard-shell {
1478
  @apply min-h-svh;
1479
  background-color: var(--lecturer-bg);
 
1611
  color: var(--lecturer-danger);
1612
  }
1613
 
1614
+ .lecturer-confirm-dialog {
1615
+ @apply gap-5 rounded-[16px] border p-6 shadow-none sm:max-w-[440px];
1616
+ background-color: var(--lecturer-surface);
1617
+ border-color: var(--lecturer-border);
1618
+ box-shadow: var(--lecturer-shadow);
1619
+ color: var(--lecturer-text);
1620
+ }
1621
+
1622
+ .lecturer-confirm-dialog-footer {
1623
+ @apply gap-3 pt-1;
1624
+ }
1625
+
1626
+ .lecturer-danger-button {
1627
+ @apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
1628
+ background-color: var(--lecturer-danger) !important;
1629
+ color: var(--lecturer-primary-foreground) !important;
1630
+ }
1631
+
1632
+ .lecturer-danger-button:hover {
1633
+ background-color: color-mix(
1634
+ in oklab,
1635
+ var(--lecturer-danger) 86%,
1636
+ #000
1637
+ ) !important;
1638
+ }
1639
+
1640
  .group[data-collapsible='icon'] .lecturer-sidebar-header {
1641
  justify-content: center !important;
1642
  padding: 0 !important;
 
1912
  color: var(--lecturer-danger);
1913
  }
1914
 
1915
+ .lecturer-status--success {
1916
+ background-color: color-mix(
1917
+ in oklab,
1918
+ var(--lecturer-success) 8%,
1919
+ var(--lecturer-surface)
1920
+ );
1921
+ border-color: color-mix(
1922
+ in oklab,
1923
+ var(--lecturer-success) 24%,
1924
+ transparent
1925
+ );
1926
+ color: var(--lecturer-success);
1927
+ }
1928
+
1929
  .lecturer-table-wrap {
1930
  @apply w-full overflow-x-auto;
1931
  }
resources/js/app.tsx CHANGED
@@ -9,10 +9,12 @@ import SettingsLayout from '@/layouts/settings/layout';
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({
 
9
  const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
10
  const fullScreenPages = new Set([
11
  'welcome',
12
+ 'auth/admin-login',
13
  'auth/login',
14
  'auth/register-choice',
15
  'auth/register-dosen',
16
  'auth/register-mahasiswa',
17
+ 'embed',
18
  ]);
19
 
20
  createInertiaApp({
resources/js/components/admin/admin-shell.tsx ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { router } from '@inertiajs/react';
2
+ import { LayoutDashboard, LogOut, MessageSquare, Settings } from 'lucide-react';
3
+ import type { CSSProperties, ReactNode } from 'react';
4
+ import { useState, useSyncExternalStore } from 'react';
5
+
6
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
7
+ import { Button } from '@/components/ui/button';
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '@/components/ui/dialog';
16
+ import {
17
+ Sidebar,
18
+ SidebarContent,
19
+ SidebarFooter,
20
+ SidebarGroup,
21
+ SidebarGroupContent,
22
+ SidebarHeader,
23
+ SidebarInset,
24
+ SidebarMenu,
25
+ SidebarMenuButton,
26
+ SidebarMenuItem,
27
+ SidebarProvider,
28
+ SidebarRail,
29
+ SidebarTrigger,
30
+ useSidebar,
31
+ } from '@/components/ui/sidebar';
32
+ import { clearAuthSession, getStoredAuthUser } from '@/lib/auth';
33
+ import {
34
+ dashboard as adminDashboard,
35
+ login as adminLogin,
36
+ pengaturan as adminPengaturan,
37
+ } from '@/routes/admin';
38
+
39
+ type AdminMenu = 'dashboard' | 'settings';
40
+
41
+ const adminSidebarStyle = {
42
+ '--sidebar-width': '212px',
43
+ '--sidebar-width-icon': '64px',
44
+ } as CSSProperties;
45
+
46
+ function getStoredAdminName(): string {
47
+ return getStoredAuthUser()?.name ?? 'Admin';
48
+ }
49
+
50
+ function subscribeToAuthStorage(onStoreChange: () => void): () => void {
51
+ if (typeof window === 'undefined') {
52
+ return () => {};
53
+ }
54
+
55
+ window.addEventListener('storage', onStoreChange);
56
+
57
+ return () => window.removeEventListener('storage', onStoreChange);
58
+ }
59
+
60
+ function useStoredAdminName(): string {
61
+ return useSyncExternalStore(
62
+ subscribeToAuthStorage,
63
+ getStoredAdminName,
64
+ () => 'Admin',
65
+ );
66
+ }
67
+
68
+ function getInitials(name: string): string {
69
+ return name
70
+ .split(' ')
71
+ .filter(Boolean)
72
+ .slice(0, 2)
73
+ .map((part) => part[0]?.toUpperCase())
74
+ .join('');
75
+ }
76
+
77
+ function confirmLogout(): void {
78
+ clearAuthSession();
79
+ router.visit(adminLogin());
80
+ }
81
+
82
+ function AdminSidebarBrand() {
83
+ const { state, toggleSidebar } = useSidebar();
84
+
85
+ const handleBrandClick = (): void => {
86
+ if (state === 'collapsed') {
87
+ toggleSidebar();
88
+ }
89
+ };
90
+
91
+ return (
92
+ <button
93
+ aria-label="Buka sidebar"
94
+ className="lecturer-brand"
95
+ onClick={handleBrandClick}
96
+ type="button"
97
+ >
98
+ <span className="lecturer-brand-mark">
99
+ <MessageSquare className="size-4" />
100
+ </span>
101
+ <span className="lecturer-brand-copy">RAG Hub</span>
102
+ </button>
103
+ );
104
+ }
105
+
106
+ function AdminSidebar({ activeMenu }: { activeMenu: AdminMenu }) {
107
+ const adminName = useStoredAdminName();
108
+ const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
109
+
110
+ return (
111
+ <>
112
+ <Sidebar className="lecturer-sidebar" collapsible="icon">
113
+ <SidebarHeader className="lecturer-sidebar-header">
114
+ <AdminSidebarBrand />
115
+
116
+ <SidebarTrigger className="lecturer-sidebar-trigger" />
117
+ </SidebarHeader>
118
+
119
+ <SidebarContent className="lecturer-sidebar-content">
120
+ <SidebarGroup className="lecturer-nav-group">
121
+ <SidebarGroupContent>
122
+ <SidebarMenu className="lecturer-nav-list">
123
+ <SidebarMenuItem>
124
+ <SidebarMenuButton
125
+ className="lecturer-nav-button"
126
+ isActive={activeMenu === 'dashboard'}
127
+ onClick={() =>
128
+ router.visit(adminDashboard())
129
+ }
130
+ tooltip="Dashboard Admin"
131
+ >
132
+ <LayoutDashboard className="size-4" />
133
+ <span>Dashboard Admin</span>
134
+ </SidebarMenuButton>
135
+ </SidebarMenuItem>
136
+
137
+ <SidebarMenuItem>
138
+ <SidebarMenuButton
139
+ className="lecturer-nav-button"
140
+ isActive={activeMenu === 'settings'}
141
+ onClick={() =>
142
+ router.visit(adminPengaturan())
143
+ }
144
+ tooltip="Pengaturan"
145
+ >
146
+ <Settings className="size-4" />
147
+ <span>Pengaturan</span>
148
+ </SidebarMenuButton>
149
+ </SidebarMenuItem>
150
+ </SidebarMenu>
151
+ </SidebarGroupContent>
152
+ </SidebarGroup>
153
+ </SidebarContent>
154
+
155
+ <SidebarFooter className="lecturer-sidebar-footer">
156
+ <SidebarMenu>
157
+ <SidebarMenuItem>
158
+ <SidebarMenuButton
159
+ className="lecturer-user-button"
160
+ tooltip={adminName}
161
+ >
162
+ <Avatar className="lecturer-user-avatar">
163
+ <AvatarFallback>
164
+ {getInitials(adminName) || 'AD'}
165
+ </AvatarFallback>
166
+ </Avatar>
167
+ <span className="lecturer-user-copy">
168
+ <span className="lecturer-user-name">
169
+ {adminName}
170
+ </span>
171
+ <span className="lecturer-user-role">
172
+ Admin
173
+ </span>
174
+ </span>
175
+ </SidebarMenuButton>
176
+ </SidebarMenuItem>
177
+
178
+ <SidebarMenuItem>
179
+ <SidebarMenuButton
180
+ className="lecturer-logout-button"
181
+ onClick={() => setIsLogoutDialogOpen(true)}
182
+ tooltip="Logout"
183
+ >
184
+ <LogOut className="size-4" />
185
+ <span>Logout</span>
186
+ </SidebarMenuButton>
187
+ </SidebarMenuItem>
188
+ </SidebarMenu>
189
+ </SidebarFooter>
190
+
191
+ <SidebarRail />
192
+ </Sidebar>
193
+
194
+ <Dialog
195
+ onOpenChange={setIsLogoutDialogOpen}
196
+ open={isLogoutDialogOpen}
197
+ >
198
+ <DialogContent className="lecturer-confirm-dialog">
199
+ <DialogHeader>
200
+ <DialogTitle>Keluar dari akun admin?</DialogTitle>
201
+ <DialogDescription>
202
+ Anda perlu login kembali untuk mengakses dashboard
203
+ admin.
204
+ </DialogDescription>
205
+ </DialogHeader>
206
+
207
+ <DialogFooter className="lecturer-confirm-dialog-footer">
208
+ <Button
209
+ className="lecturer-cancel-button"
210
+ onClick={() => setIsLogoutDialogOpen(false)}
211
+ type="button"
212
+ variant="outline"
213
+ >
214
+ Batal
215
+ </Button>
216
+ <Button
217
+ className="lecturer-danger-button"
218
+ onClick={confirmLogout}
219
+ type="button"
220
+ >
221
+ Logout
222
+ </Button>
223
+ </DialogFooter>
224
+ </DialogContent>
225
+ </Dialog>
226
+ </>
227
+ );
228
+ }
229
+
230
+ export function AdminShell({
231
+ activeMenu = 'dashboard',
232
+ children,
233
+ }: {
234
+ activeMenu?: AdminMenu;
235
+ children: ReactNode;
236
+ }) {
237
+ return (
238
+ <SidebarProvider
239
+ className="lecturer-dashboard-shell"
240
+ defaultOpen
241
+ style={adminSidebarStyle}
242
+ >
243
+ <AdminSidebar activeMenu={activeMenu} />
244
+
245
+ <SidebarInset className="lecturer-workspace">
246
+ {children}
247
+ </SidebarInset>
248
+ </SidebarProvider>
249
+ );
250
+ }
resources/js/components/dosen/dosen-shell.tsx CHANGED
@@ -1,9 +1,18 @@
1
  import { router } from '@inertiajs/react';
2
  import { LayoutDashboard, LogOut, MessageSquare, Settings } from 'lucide-react';
3
  import type { CSSProperties, ReactNode } from 'react';
4
- import { useSyncExternalStore } from 'react';
5
 
6
  import { Avatar, AvatarFallback } from '@/components/ui/avatar';
 
 
 
 
 
 
 
 
 
7
  import {
8
  Sidebar,
9
  SidebarContent,
@@ -62,7 +71,7 @@ function getInitials(name: string): string {
62
  .join('');
63
  }
64
 
65
- function handleLogout(): void {
66
  clearAuthSession();
67
  router.visit(login());
68
  }
@@ -93,87 +102,123 @@ function DosenSidebarBrand() {
93
 
94
  function DosenSidebar({ activeMenu }: { activeMenu: DosenMenu }) {
95
  const lecturerName = useStoredLecturerName();
 
96
 
97
  return (
98
- <Sidebar className="lecturer-sidebar" collapsible="icon">
99
- <SidebarHeader className="lecturer-sidebar-header">
100
- <DosenSidebarBrand />
101
-
102
- <SidebarTrigger className="lecturer-sidebar-trigger" />
103
- </SidebarHeader>
104
-
105
- <SidebarContent className="lecturer-sidebar-content">
106
- <SidebarGroup className="lecturer-nav-group">
107
- <SidebarGroupContent>
108
- <SidebarMenu className="lecturer-nav-list">
109
- <SidebarMenuItem>
110
- <SidebarMenuButton
111
- className="lecturer-nav-button"
112
- isActive={activeMenu === 'dashboard'}
113
- onClick={() => router.visit(dosen())}
114
- tooltip="Dashboard KB"
115
- >
116
- <LayoutDashboard className="size-4" />
117
- <span>Dashboard KB</span>
118
- </SidebarMenuButton>
119
- </SidebarMenuItem>
120
-
121
- <SidebarMenuItem>
122
- <SidebarMenuButton
123
- className="lecturer-nav-button"
124
- isActive={activeMenu === 'settings'}
125
- onClick={() =>
126
- router.visit(dosenPengaturan())
127
- }
128
- tooltip="Pengaturan"
129
- >
130
- <Settings className="size-4" />
131
- <span>Pengaturan</span>
132
- </SidebarMenuButton>
133
- </SidebarMenuItem>
134
- </SidebarMenu>
135
- </SidebarGroupContent>
136
- </SidebarGroup>
137
- </SidebarContent>
138
-
139
- <SidebarFooter className="lecturer-sidebar-footer">
140
- <SidebarMenu>
141
- <SidebarMenuItem>
142
- <SidebarMenuButton
143
- className="lecturer-user-button"
144
- tooltip={lecturerName}
145
- >
146
- <Avatar className="lecturer-user-avatar">
147
- <AvatarFallback>
148
- {getInitials(lecturerName) || 'DS'}
149
- </AvatarFallback>
150
- </Avatar>
151
- <span className="lecturer-user-copy">
152
- <span className="lecturer-user-name">
153
- {lecturerName}
 
 
 
 
 
154
  </span>
155
- <span className="lecturer-user-role">
156
- Dosen
157
- </span>
158
- </span>
159
- </SidebarMenuButton>
160
- </SidebarMenuItem>
161
-
162
- <SidebarMenuItem>
163
- <SidebarMenuButton
164
- className="lecturer-logout-button"
165
- onClick={handleLogout}
166
- tooltip="Logout"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  >
168
- <LogOut className="size-4" />
169
- <span>Logout</span>
170
- </SidebarMenuButton>
171
- </SidebarMenuItem>
172
- </SidebarMenu>
173
- </SidebarFooter>
174
-
175
- <SidebarRail />
176
- </Sidebar>
177
  );
178
  }
179
 
 
1
  import { router } from '@inertiajs/react';
2
  import { LayoutDashboard, LogOut, MessageSquare, Settings } from 'lucide-react';
3
  import type { CSSProperties, ReactNode } from 'react';
4
+ import { useState, useSyncExternalStore } from 'react';
5
 
6
  import { Avatar, AvatarFallback } from '@/components/ui/avatar';
7
+ import { Button } from '@/components/ui/button';
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '@/components/ui/dialog';
16
  import {
17
  Sidebar,
18
  SidebarContent,
 
71
  .join('');
72
  }
73
 
74
+ function confirmLogout(): void {
75
  clearAuthSession();
76
  router.visit(login());
77
  }
 
102
 
103
  function DosenSidebar({ activeMenu }: { activeMenu: DosenMenu }) {
104
  const lecturerName = useStoredLecturerName();
105
+ const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
106
 
107
  return (
108
+ <>
109
+ <Sidebar className="lecturer-sidebar" collapsible="icon">
110
+ <SidebarHeader className="lecturer-sidebar-header">
111
+ <DosenSidebarBrand />
112
+
113
+ <SidebarTrigger className="lecturer-sidebar-trigger" />
114
+ </SidebarHeader>
115
+
116
+ <SidebarContent className="lecturer-sidebar-content">
117
+ <SidebarGroup className="lecturer-nav-group">
118
+ <SidebarGroupContent>
119
+ <SidebarMenu className="lecturer-nav-list">
120
+ <SidebarMenuItem>
121
+ <SidebarMenuButton
122
+ className="lecturer-nav-button"
123
+ isActive={activeMenu === 'dashboard'}
124
+ onClick={() => router.visit(dosen())}
125
+ tooltip="Dashboard KB"
126
+ >
127
+ <LayoutDashboard className="size-4" />
128
+ <span>Dashboard KB</span>
129
+ </SidebarMenuButton>
130
+ </SidebarMenuItem>
131
+
132
+ <SidebarMenuItem>
133
+ <SidebarMenuButton
134
+ className="lecturer-nav-button"
135
+ isActive={activeMenu === 'settings'}
136
+ onClick={() =>
137
+ router.visit(dosenPengaturan())
138
+ }
139
+ tooltip="Pengaturan"
140
+ >
141
+ <Settings className="size-4" />
142
+ <span>Pengaturan</span>
143
+ </SidebarMenuButton>
144
+ </SidebarMenuItem>
145
+ </SidebarMenu>
146
+ </SidebarGroupContent>
147
+ </SidebarGroup>
148
+ </SidebarContent>
149
+
150
+ <SidebarFooter className="lecturer-sidebar-footer">
151
+ <SidebarMenu>
152
+ <SidebarMenuItem>
153
+ <SidebarMenuButton
154
+ className="lecturer-user-button"
155
+ tooltip={lecturerName}
156
+ >
157
+ <Avatar className="lecturer-user-avatar">
158
+ <AvatarFallback>
159
+ {getInitials(lecturerName) || 'DS'}
160
+ </AvatarFallback>
161
+ </Avatar>
162
+ <span className="lecturer-user-copy">
163
+ <span className="lecturer-user-name">
164
+ {lecturerName}
165
+ </span>
166
+ <span className="lecturer-user-role">
167
+ Dosen
168
+ </span>
169
  </span>
170
+ </SidebarMenuButton>
171
+ </SidebarMenuItem>
172
+
173
+ <SidebarMenuItem>
174
+ <SidebarMenuButton
175
+ className="lecturer-logout-button"
176
+ onClick={() => setIsLogoutDialogOpen(true)}
177
+ tooltip="Logout"
178
+ >
179
+ <LogOut className="size-4" />
180
+ <span>Logout</span>
181
+ </SidebarMenuButton>
182
+ </SidebarMenuItem>
183
+ </SidebarMenu>
184
+ </SidebarFooter>
185
+
186
+ <SidebarRail />
187
+ </Sidebar>
188
+
189
+ <Dialog
190
+ onOpenChange={setIsLogoutDialogOpen}
191
+ open={isLogoutDialogOpen}
192
+ >
193
+ <DialogContent className="lecturer-confirm-dialog">
194
+ <DialogHeader>
195
+ <DialogTitle>Keluar dari akun?</DialogTitle>
196
+ <DialogDescription>
197
+ Anda perlu login kembali untuk mengakses dashboard
198
+ dosen.
199
+ </DialogDescription>
200
+ </DialogHeader>
201
+
202
+ <DialogFooter className="lecturer-confirm-dialog-footer">
203
+ <Button
204
+ className="lecturer-cancel-button"
205
+ onClick={() => setIsLogoutDialogOpen(false)}
206
+ type="button"
207
+ variant="outline"
208
+ >
209
+ Batal
210
+ </Button>
211
+ <Button
212
+ className="lecturer-danger-button"
213
+ onClick={confirmLogout}
214
+ type="button"
215
  >
216
+ Logout
217
+ </Button>
218
+ </DialogFooter>
219
+ </DialogContent>
220
+ </Dialog>
221
+ </>
 
 
 
222
  );
223
  }
224
 
resources/js/components/student/student-shell.tsx CHANGED
@@ -107,7 +107,7 @@ function getInitials(name: string): string {
107
  .join('');
108
  }
109
 
110
- function handleLogout(): void {
111
  clearAuthSession();
112
  router.visit(login());
113
  }
@@ -153,6 +153,7 @@ function StudentSidebar({
153
  const [isHistoryOpen, setIsHistoryOpen] = useState(true);
154
  const [deleteDialogMessage, setDeleteDialogMessage] = useState<string>();
155
  const [isDeletingSession, setIsDeletingSession] = useState(false);
 
156
  const [sessionToDelete, setSessionToDelete] =
157
  useState<ChatSessionResponse>();
158
  const { state } = useSidebar();
@@ -366,7 +367,7 @@ function StudentSidebar({
366
  <SidebarMenuItem>
367
  <SidebarMenuButton
368
  className="student-logout-button"
369
- onClick={handleLogout}
370
  tooltip="Logout"
371
  >
372
  <LogOut className="size-4" />
@@ -437,6 +438,38 @@ function StudentSidebar({
437
  </DialogFooter>
438
  </DialogContent>
439
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  </>
441
  );
442
  }
 
107
  .join('');
108
  }
109
 
110
+ function confirmLogout(): void {
111
  clearAuthSession();
112
  router.visit(login());
113
  }
 
153
  const [isHistoryOpen, setIsHistoryOpen] = useState(true);
154
  const [deleteDialogMessage, setDeleteDialogMessage] = useState<string>();
155
  const [isDeletingSession, setIsDeletingSession] = useState(false);
156
+ const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
157
  const [sessionToDelete, setSessionToDelete] =
158
  useState<ChatSessionResponse>();
159
  const { state } = useSidebar();
 
367
  <SidebarMenuItem>
368
  <SidebarMenuButton
369
  className="student-logout-button"
370
+ onClick={() => setIsLogoutDialogOpen(true)}
371
  tooltip="Logout"
372
  >
373
  <LogOut className="size-4" />
 
438
  </DialogFooter>
439
  </DialogContent>
440
  </Dialog>
441
+
442
+ <Dialog
443
+ onOpenChange={setIsLogoutDialogOpen}
444
+ open={isLogoutDialogOpen}
445
+ >
446
+ <DialogContent className="student-delete-dialog">
447
+ <DialogHeader>
448
+ <DialogTitle>Keluar dari akun?</DialogTitle>
449
+ <DialogDescription>
450
+ Anda perlu login kembali untuk mengakses sesi chat.
451
+ </DialogDescription>
452
+ </DialogHeader>
453
+
454
+ <DialogFooter className="student-delete-dialog-footer">
455
+ <Button
456
+ className="student-delete-cancel"
457
+ onClick={() => setIsLogoutDialogOpen(false)}
458
+ type="button"
459
+ variant="outline"
460
+ >
461
+ Batal
462
+ </Button>
463
+ <Button
464
+ className="student-delete-confirm"
465
+ onClick={confirmLogout}
466
+ type="button"
467
+ >
468
+ Logout
469
+ </Button>
470
+ </DialogFooter>
471
+ </DialogContent>
472
+ </Dialog>
473
  </>
474
  );
475
  }
resources/js/components/ui/sonner.tsx CHANGED
@@ -17,6 +17,9 @@ function Toaster({ ...props }: ToasterProps) {
17
  '--normal-bg': 'var(--popover)',
18
  '--normal-text': 'var(--popover-foreground)',
19
  '--normal-border': 'var(--border)',
 
 
 
20
  } as React.CSSProperties
21
  }
22
  {...props}
 
17
  '--normal-bg': 'var(--popover)',
18
  '--normal-text': 'var(--popover-foreground)',
19
  '--normal-border': 'var(--border)',
20
+ '--success-bg': 'var(--app-primary-soft)',
21
+ '--success-border': 'var(--app-border-strong)',
22
+ '--success-text': 'var(--app-primary)',
23
  } as React.CSSProperties
24
  }
25
  {...props}
resources/js/hooks/use-flash-toast.ts CHANGED
@@ -1,19 +1,42 @@
1
  import { router } from '@inertiajs/react';
2
- import { useEffect } from 'react';
3
  import { toast } from 'sonner';
4
  import type { FlashToast } from '@/types/ui';
5
 
6
  export function useFlashToast(): void {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  useEffect(() => {
8
  return router.on('flash', (event) => {
9
- const flash = (event as CustomEvent).detail?.flash;
10
- const data = flash?.toast as FlashToast | undefined;
 
 
 
11
 
12
- if (!data) {
13
- return;
14
- }
15
-
16
- toast[data.type](data.message);
17
  });
18
- }, []);
19
  }
 
1
  import { router } from '@inertiajs/react';
2
+ import { useCallback, useEffect, useRef } from 'react';
3
  import { toast } from 'sonner';
4
  import type { FlashToast } from '@/types/ui';
5
 
6
  export function useFlashToast(): void {
7
+ const lastToastRef = useRef<
8
+ { signature: string; timestamp: number } | undefined
9
+ >(undefined);
10
+
11
+ const showFlashToast = useCallback((data?: FlashToast | null): void => {
12
+ if (!data) {
13
+ return;
14
+ }
15
+
16
+ const signature = `${data.type}:${data.message}`;
17
+ const timestamp = Date.now();
18
+ const lastToast = lastToastRef.current;
19
+
20
+ if (
21
+ lastToast?.signature === signature &&
22
+ timestamp - lastToast.timestamp < 1000
23
+ ) {
24
+ return;
25
+ }
26
+
27
+ lastToastRef.current = { signature, timestamp };
28
+ toast[data.type](data.message);
29
+ }, []);
30
+
31
  useEffect(() => {
32
  return router.on('flash', (event) => {
33
+ const flash = (
34
+ event as CustomEvent<{
35
+ flash?: { toast?: FlashToast | null };
36
+ }>
37
+ ).detail?.flash;
38
 
39
+ showFlashToast(flash?.toast);
 
 
 
 
40
  });
41
+ }, [showFlashToast]);
42
  }
resources/js/pages/admin.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head } from '@inertiajs/react';
2
+
3
+ import { AdminShell } from '@/components/admin/admin-shell';
4
+ import AppearanceToggle from '@/components/appearance-toggle';
5
+
6
+ export default function AdminDashboard() {
7
+ return (
8
+ <>
9
+ <Head title="Dashboard Admin" />
10
+
11
+ <AdminShell>
12
+ <main className="lecturer-page">
13
+ <section className="lecturer-page-header">
14
+ <div>
15
+ <h1>Dashboard Admin</h1>
16
+ <p>Halaman ini masih kosong.</p>
17
+ </div>
18
+
19
+ <div className="lecturer-header-actions">
20
+ <AppearanceToggle className="lecturer-theme-toggle" />
21
+ </div>
22
+ </section>
23
+ </main>
24
+ </AdminShell>
25
+ </>
26
+ );
27
+ }
resources/js/pages/admin/pengaturan.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head } from '@inertiajs/react';
2
+
3
+ import { AdminShell } from '@/components/admin/admin-shell';
4
+
5
+ export default function PengaturanAdmin() {
6
+ return (
7
+ <>
8
+ <Head title="Pengaturan Admin" />
9
+
10
+ <AdminShell activeMenu="settings">
11
+ <main className="lecturer-page">
12
+ <section className="lecturer-page-header">
13
+ <div>
14
+ <h1>Pengaturan</h1>
15
+ <p>Halaman ini masih kosong.</p>
16
+ </div>
17
+ </section>
18
+ </main>
19
+ </AdminShell>
20
+ </>
21
+ );
22
+ }
resources/js/pages/auth/admin-login.tsx ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, Link, useForm, usePage } from '@inertiajs/react';
2
+ import type { FormEvent } from 'react';
3
+ import { toast } from 'sonner';
4
+
5
+ import AppearanceToggle from '@/components/appearance-toggle';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Card, CardContent } from '@/components/ui/card';
8
+ import { Checkbox } from '@/components/ui/checkbox';
9
+ import { Input } from '@/components/ui/input';
10
+ import { Label } from '@/components/ui/label';
11
+ import { LoadingIndicator } from '@/components/ui/loading-indicator';
12
+ import { home } from '@/routes';
13
+ import { store as adminLoginStore } from '@/routes/admin/login';
14
+ import type { FlashToast } from '@/types/ui';
15
+
16
+ type AdminLoginProps = {
17
+ status?: string;
18
+ };
19
+
20
+ type AdminLoginPageProps = {
21
+ flash?: {
22
+ status?: string | null;
23
+ toast?: FlashToast | null;
24
+ };
25
+ };
26
+
27
+ type AdminLoginForm = {
28
+ email: string;
29
+ password: string;
30
+ remember: boolean;
31
+ };
32
+
33
+ export default function AdminLogin({ status }: AdminLoginProps) {
34
+ const { flash } = usePage<AdminLoginPageProps>().props;
35
+ const { data, errors, post, processing, setData } =
36
+ useForm<AdminLoginForm>({
37
+ email: '',
38
+ password: '',
39
+ remember: true,
40
+ });
41
+ const formErrors = errors as Partial<
42
+ Record<keyof AdminLoginForm | 'auth', string>
43
+ >;
44
+ const backendError =
45
+ formErrors.auth ?? formErrors.email ?? formErrors.password;
46
+ const statusMessage = status ?? flash?.status;
47
+
48
+ const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
49
+ event.preventDefault();
50
+
51
+ post(adminLoginStore.url(), {
52
+ onSuccess: () => toast.success('Login admin berhasil.'),
53
+ preserveScroll: true,
54
+ });
55
+ };
56
+
57
+ return (
58
+ <>
59
+ <Head title="Admin Login" />
60
+
61
+ <main className="register-shell">
62
+ <AppearanceToggle />
63
+
64
+ <Card className="register-card login-card">
65
+ <CardContent className="login-card-content">
66
+ <Link href={home()} className="register-back-link">
67
+ &larr; Kembali
68
+ </Link>
69
+
70
+ <h1 className="register-heading login-heading">
71
+ Admin Login
72
+ </h1>
73
+
74
+ <form className="login-form" onSubmit={handleSubmit}>
75
+ <div className="register-field">
76
+ <Label
77
+ htmlFor="admin-email"
78
+ className="register-label"
79
+ >
80
+ Email
81
+ </Label>
82
+ <Input
83
+ id="admin-email"
84
+ name="email"
85
+ type="email"
86
+ autoComplete="email"
87
+ autoFocus
88
+ onChange={(event) =>
89
+ setData('email', event.target.value)
90
+ }
91
+ placeholder="Masukkan email admin"
92
+ tabIndex={1}
93
+ value={data.email}
94
+ className="register-input"
95
+ />
96
+ </div>
97
+
98
+ <div className="register-field">
99
+ <Label
100
+ htmlFor="admin-password"
101
+ className="register-label"
102
+ >
103
+ Password
104
+ </Label>
105
+ <Input
106
+ id="admin-password"
107
+ name="password"
108
+ type="password"
109
+ autoComplete="current-password"
110
+ onChange={(event) =>
111
+ setData('password', event.target.value)
112
+ }
113
+ placeholder="********"
114
+ tabIndex={2}
115
+ value={data.password}
116
+ className="register-input"
117
+ />
118
+ </div>
119
+
120
+ <div className="login-remember">
121
+ <Checkbox
122
+ id="admin-remember"
123
+ name="remember"
124
+ checked={data.remember}
125
+ onCheckedChange={(checked) => {
126
+ setData('remember', checked === true);
127
+ }}
128
+ tabIndex={3}
129
+ />
130
+ <Label
131
+ htmlFor="admin-remember"
132
+ className="login-remember-label"
133
+ >
134
+ Remember me
135
+ </Label>
136
+ </div>
137
+
138
+ <Button
139
+ type="submit"
140
+ className="register-submit login-submit"
141
+ tabIndex={4}
142
+ disabled={processing}
143
+ data-test="admin-login-button"
144
+ >
145
+ {processing ? (
146
+ <LoadingIndicator
147
+ label="Masuk"
148
+ spinnerClassName="size-3.5"
149
+ />
150
+ ) : (
151
+ 'Sign In'
152
+ )}
153
+ </Button>
154
+ </form>
155
+
156
+ {(backendError ?? statusMessage) && (
157
+ <p
158
+ className={
159
+ backendError
160
+ ? 'login-status login-error'
161
+ : 'login-status'
162
+ }
163
+ role={backendError ? 'alert' : undefined}
164
+ >
165
+ {backendError ?? statusMessage}
166
+ </p>
167
+ )}
168
+ </CardContent>
169
+ </Card>
170
+ </main>
171
+ </>
172
+ );
173
+ }
resources/js/pages/auth/forgot-password.tsx CHANGED
@@ -15,7 +15,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
15
  <Head title="Forgot password" />
16
 
17
  {status && (
18
- <div className="mb-4 text-center text-sm font-medium text-green-600">
19
  {status}
20
  </div>
21
  )}
 
15
  <Head title="Forgot password" />
16
 
17
  {status && (
18
+ <div className="mb-4 text-center text-sm font-medium text-primary">
19
  {status}
20
  </div>
21
  )}
resources/js/pages/auth/login.tsx CHANGED
@@ -1,5 +1,7 @@
1
- import { Head, Link, useForm } from '@inertiajs/react';
2
  import type { FormEvent } from 'react';
 
 
3
  import AppearanceToggle from '@/components/appearance-toggle';
4
  import { Button } from '@/components/ui/button';
5
  import { Card, CardContent } from '@/components/ui/card';
@@ -9,12 +11,20 @@ import { Label } from '@/components/ui/label';
9
  import { LoadingIndicator } from '@/components/ui/loading-indicator';
10
  import { home, register } from '@/routes';
11
  import { store as loginStore } from '@/routes/auth/login';
 
12
 
13
  type LoginProps = {
14
  canRegister?: boolean;
15
  status?: string;
16
  };
17
 
 
 
 
 
 
 
 
18
  type LoginForm = {
19
  email: string;
20
  password: string;
@@ -22,19 +32,24 @@ type LoginForm = {
22
  };
23
 
24
  export default function Login({ canRegister = true, status }: LoginProps) {
 
25
  const { data, errors, post, processing, setData } = useForm<LoginForm>({
26
  email: '',
27
  password: '',
28
  remember: true,
29
  });
30
- const formErrors = errors as Partial<Record<keyof LoginForm | 'auth', string>>;
 
 
31
  const backendError =
32
  formErrors.auth ?? formErrors.email ?? formErrors.password;
 
33
 
34
  const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
35
  event.preventDefault();
36
 
37
  post(loginStore.url(), {
 
38
  preserveScroll: true,
39
  });
40
  };
@@ -138,7 +153,7 @@ export default function Login({ canRegister = true, status }: LoginProps) {
138
  </Button>
139
  </form>
140
 
141
- {(backendError ?? status) && (
142
  <p
143
  className={
144
  backendError
@@ -147,7 +162,7 @@ export default function Login({ canRegister = true, status }: LoginProps) {
147
  }
148
  role={backendError ? 'alert' : undefined}
149
  >
150
- {backendError ?? status}
151
  </p>
152
  )}
153
 
 
1
+ import { Head, Link, useForm, usePage } from '@inertiajs/react';
2
  import type { FormEvent } from 'react';
3
+ import { toast } from 'sonner';
4
+
5
  import AppearanceToggle from '@/components/appearance-toggle';
6
  import { Button } from '@/components/ui/button';
7
  import { Card, CardContent } from '@/components/ui/card';
 
11
  import { LoadingIndicator } from '@/components/ui/loading-indicator';
12
  import { home, register } from '@/routes';
13
  import { store as loginStore } from '@/routes/auth/login';
14
+ import type { FlashToast } from '@/types/ui';
15
 
16
  type LoginProps = {
17
  canRegister?: boolean;
18
  status?: string;
19
  };
20
 
21
+ type LoginPageProps = {
22
+ flash?: {
23
+ status?: string | null;
24
+ toast?: FlashToast | null;
25
+ };
26
+ };
27
+
28
  type LoginForm = {
29
  email: string;
30
  password: string;
 
32
  };
33
 
34
  export default function Login({ canRegister = true, status }: LoginProps) {
35
+ const { flash } = usePage<LoginPageProps>().props;
36
  const { data, errors, post, processing, setData } = useForm<LoginForm>({
37
  email: '',
38
  password: '',
39
  remember: true,
40
  });
41
+ const formErrors = errors as Partial<
42
+ Record<keyof LoginForm | 'auth', string>
43
+ >;
44
  const backendError =
45
  formErrors.auth ?? formErrors.email ?? formErrors.password;
46
+ const statusMessage = status ?? flash?.status;
47
 
48
  const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
49
  event.preventDefault();
50
 
51
  post(loginStore.url(), {
52
+ onSuccess: () => toast.success('Login berhasil.'),
53
  preserveScroll: true,
54
  });
55
  };
 
153
  </Button>
154
  </form>
155
 
156
+ {(backendError ?? statusMessage) && (
157
  <p
158
  className={
159
  backendError
 
162
  }
163
  role={backendError ? 'alert' : undefined}
164
  >
165
+ {backendError ?? statusMessage}
166
  </p>
167
  )}
168
 
resources/js/pages/auth/verify-email.tsx CHANGED
@@ -12,7 +12,7 @@ export default function VerifyEmail({ status }: { status?: string }) {
12
  <Head title="Email verification" />
13
 
14
  {status === 'verification-link-sent' && (
15
- <div className="mb-4 text-center text-sm font-medium text-green-600">
16
  A new verification link has been sent to the email address
17
  you provided during registration.
18
  </div>
 
12
  <Head title="Email verification" />
13
 
14
  {status === 'verification-link-sent' && (
15
+ <div className="mb-4 text-center text-sm font-medium text-primary">
16
  A new verification link has been sent to the email address
17
  you provided during registration.
18
  </div>
resources/js/pages/dosen-detail.tsx CHANGED
@@ -10,7 +10,7 @@ import {
10
  Upload,
11
  } from 'lucide-react';
12
  import type { ChangeEvent, DragEvent, FormEvent } from 'react';
13
- import { useRef, useState } from 'react';
14
 
15
  import { DosenShell } from '@/components/dosen/dosen-shell';
16
  import { Button } from '@/components/ui/button';
@@ -211,6 +211,23 @@ export default function DosenDetail({
211
  const deleteCourseMessage = deleteErrors.course;
212
  const isUpdatingCourse = editForm.processing;
213
  const isDeletingCourse = deleteForm.processing;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
  function handleUploadDocument(file: File): void {
216
  setUploadMessage(undefined);
 
10
  Upload,
11
  } from 'lucide-react';
12
  import type { ChangeEvent, DragEvent, FormEvent } from 'react';
13
+ import { useEffect, useRef, useState } from 'react';
14
 
15
  import { DosenShell } from '@/components/dosen/dosen-shell';
16
  import { Button } from '@/components/ui/button';
 
211
  const deleteCourseMessage = deleteErrors.course;
212
  const isUpdatingCourse = editForm.processing;
213
  const isDeletingCourse = deleteForm.processing;
214
+ const hasProcessingDocuments = documents.some(
215
+ (document) => document.status === 'processing',
216
+ );
217
+
218
+ useEffect(() => {
219
+ if (!hasProcessingDocuments) {
220
+ return;
221
+ }
222
+
223
+ const intervalId = window.setInterval(() => {
224
+ router.reload({
225
+ only: ['backendMessage', 'documents'],
226
+ });
227
+ }, 7000);
228
+
229
+ return () => window.clearInterval(intervalId);
230
+ }, [hasProcessingDocuments]);
231
 
232
  function handleUploadDocument(file: File): void {
233
  setUploadMessage(undefined);
resources/js/pages/embed.tsx ADDED
@@ -0,0 +1,721 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, router } from '@inertiajs/react';
2
+ import { BookOpen, Bot, MessageSquare, Plus, Send, Sparkles, Trash2, UserRound, X } from 'lucide-react';
3
+ import type { CSSProperties, FormEvent, KeyboardEvent } from 'react';
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
+
6
+ import { LoadingIndicator } from '@/components/ui/loading-indicator';
7
+ import { Spinner } from '@/components/ui/spinner';
8
+ import type {
9
+ ChatMessageResponse,
10
+ ChatSessionResponse,
11
+ CourseResponse,
12
+ } from '@/lib/rag';
13
+ import { createLocalUserMessage } from '@/lib/rag';
14
+ import { embed as embedRoute } from '@/routes';
15
+ import { destroy as destroyEmbedSession } from '@/routes/embed';
16
+ import { store as storeEmbedMessage } from '@/routes/embed/messages';
17
+ import { store as storeEmbedSession } from '@/routes/embed/sessions';
18
+
19
+ type EmbedConfig = {
20
+ background: string;
21
+ border: string;
22
+ courseId?: string | null;
23
+ danger: string;
24
+ muted: string;
25
+ primary: string;
26
+ primarySoft: string;
27
+ radius: string;
28
+ subtitle: string;
29
+ surface: string;
30
+ text: string;
31
+ title: string;
32
+ userName: string;
33
+ };
34
+
35
+ type PendingInitialChat = {
36
+ content: string;
37
+ courseId: string;
38
+ };
39
+
40
+ type EmbedProps = {
41
+ backendMessage?: string | null;
42
+ chatSessions: ChatSessionResponse[];
43
+ config: EmbedConfig;
44
+ courses: CourseResponse[];
45
+ currentSession?: ChatSessionResponse | null;
46
+ isAuthorized: boolean;
47
+ isDirectChatMode?: boolean;
48
+ messages: ChatMessageResponse[];
49
+ pendingInitialChat?: PendingInitialChat | null;
50
+ selectedCourseId?: string | null;
51
+ sessionId?: string | null;
52
+ };
53
+
54
+ function isUserMessage(role: string): boolean {
55
+ return ['human', 'student', 'user'].includes(role.toLowerCase());
56
+ }
57
+
58
+ function firstError(
59
+ errors: Record<string, string | string[]>,
60
+ ): string | undefined {
61
+ const [message] = Object.values(errors);
62
+
63
+ return Array.isArray(message) ? message[0] : message;
64
+ }
65
+
66
+ function formatMessageTime(value?: string): string {
67
+ if (!value) {
68
+ return '';
69
+ }
70
+
71
+ const date = new Date(value);
72
+
73
+ if (Number.isNaN(date.getTime())) {
74
+ return value;
75
+ }
76
+
77
+ return new Intl.DateTimeFormat('en-US', {
78
+ hour: 'numeric',
79
+ minute: '2-digit',
80
+ }).format(date);
81
+ }
82
+
83
+ function configuredUrl(
84
+ baseUrl: string,
85
+ overrides: Record<string, string | null> = {},
86
+ ): string {
87
+ if (typeof window === 'undefined') {
88
+ return baseUrl;
89
+ }
90
+
91
+ const params = new URLSearchParams(window.location.search);
92
+ params.delete('api_key');
93
+ params.delete('token');
94
+
95
+ for (const [key, value] of Object.entries(overrides)) {
96
+ if (value === null || value === '') {
97
+ params.delete(key);
98
+
99
+ continue;
100
+ }
101
+
102
+ params.set(key, value);
103
+ }
104
+
105
+ const query = params.toString();
106
+
107
+ return query ? `${baseUrl}?${query}` : baseUrl;
108
+ }
109
+
110
+ function appendUniqueMessages(
111
+ currentMessages: ChatMessageResponse[],
112
+ nextMessages: ChatMessageResponse[],
113
+ ): ChatMessageResponse[] {
114
+ const existingIds = new Set(
115
+ currentMessages.map((message) => message.uuid_id),
116
+ );
117
+
118
+ return [
119
+ ...currentMessages,
120
+ ...nextMessages.filter((message) => !existingIds.has(message.uuid_id)),
121
+ ];
122
+ }
123
+
124
+ function TypingText({ text }: { text: string }) {
125
+ const [visibleLength, setVisibleLength] = useState(0);
126
+
127
+ useEffect(() => {
128
+ const intervalId = window.setInterval(() => {
129
+ setVisibleLength((currentLength) => {
130
+ if (currentLength >= text.length) {
131
+ window.clearInterval(intervalId);
132
+
133
+ return currentLength;
134
+ }
135
+
136
+ return currentLength + 1;
137
+ });
138
+ }, 14);
139
+
140
+ return () => window.clearInterval(intervalId);
141
+ }, [text]);
142
+
143
+ return (
144
+ <>
145
+ {text.slice(0, visibleLength)}
146
+ {visibleLength < text.length && (
147
+ <span className="embed-message-caret" aria-hidden />
148
+ )}
149
+ </>
150
+ );
151
+ }
152
+
153
+ function EmbedMessage({
154
+ message,
155
+ shouldAnimateTyping,
156
+ }: {
157
+ message: ChatMessageResponse;
158
+ shouldAnimateTyping?: boolean;
159
+ }) {
160
+ const isUser = isUserMessage(message.role);
161
+
162
+ return (
163
+ <article
164
+ className={
165
+ isUser
166
+ ? 'embed-message embed-message--user'
167
+ : 'embed-message embed-message--assistant'
168
+ }
169
+ >
170
+ {!isUser && (
171
+ <span className="embed-message-avatar embed-message-avatar--assistant">
172
+ <Bot className="size-4" />
173
+ </span>
174
+ )}
175
+
176
+ <div className="embed-message-bubble">
177
+ <p className="embed-message-content">
178
+ {!isUser && shouldAnimateTyping ? (
179
+ <TypingText key={message.uuid_id} text={message.content} />
180
+ ) : (
181
+ message.content
182
+ )}
183
+ </p>
184
+ <span className="embed-message-time">
185
+ {formatMessageTime(message.created_at)}
186
+ </span>
187
+ </div>
188
+
189
+ {isUser && (
190
+ <span className="embed-message-avatar embed-message-avatar--user">
191
+ <UserRound className="size-4" />
192
+ </span>
193
+ )}
194
+ </article>
195
+ );
196
+ }
197
+
198
+ function EmbedComposer({
199
+ courses,
200
+ isProcessing,
201
+ onCourseChange,
202
+ onQuestionChange,
203
+ onSubmit,
204
+ question,
205
+ selectedCourseId,
206
+ }: {
207
+ courses: CourseResponse[];
208
+ isProcessing: boolean;
209
+ onCourseChange: (courseId: string) => void;
210
+ onQuestionChange: (question: string) => void;
211
+ onSubmit: (event: FormEvent<HTMLFormElement>) => void;
212
+ question: string;
213
+ selectedCourseId: string;
214
+ }) {
215
+ const canSubmit =
216
+ !isProcessing &&
217
+ selectedCourseId.length > 0 &&
218
+ question.trim().length > 0;
219
+
220
+ function handleQuestionKeyDown(
221
+ event: KeyboardEvent<HTMLTextAreaElement>,
222
+ ): void {
223
+ if (
224
+ event.key !== 'Enter' ||
225
+ event.shiftKey ||
226
+ event.nativeEvent.isComposing
227
+ ) {
228
+ return;
229
+ }
230
+
231
+ event.preventDefault();
232
+
233
+ if (canSubmit) {
234
+ event.currentTarget.form?.requestSubmit();
235
+ }
236
+ }
237
+
238
+ return (
239
+ <form className="embed-composer" onSubmit={onSubmit}>
240
+ <div className="embed-course-row">
241
+ <BookOpen className="size-4" />
242
+ <span>Mata Kuliah:</span>
243
+ <select
244
+ aria-label="Mata kuliah"
245
+ disabled={courses.length === 0}
246
+ onChange={(event) => onCourseChange(event.target.value)}
247
+ value={selectedCourseId}
248
+ >
249
+ <option value="" disabled>
250
+ Pilih mata kuliah
251
+ </option>
252
+ {courses.map((course) => (
253
+ <option key={course.id} value={String(course.id)}>
254
+ {course.title}
255
+ </option>
256
+ ))}
257
+ </select>
258
+ </div>
259
+
260
+ <div className="embed-composer-divider" />
261
+
262
+ <div className="embed-question-row">
263
+ <Sparkles className="size-4" />
264
+ <textarea
265
+ aria-label="Pertanyaan"
266
+ onChange={(event) => onQuestionChange(event.target.value)}
267
+ onKeyDown={handleQuestionKeyDown}
268
+ placeholder="Tulis pertanyaanmu disini..."
269
+ value={question}
270
+ />
271
+ </div>
272
+
273
+ <button
274
+ aria-label="Kirim pertanyaan"
275
+ className="embed-send-button"
276
+ disabled={!canSubmit}
277
+ type="submit"
278
+ >
279
+ {isProcessing ? (
280
+ <Spinner className="size-4" />
281
+ ) : (
282
+ <Send className="size-4" />
283
+ )}
284
+ </button>
285
+ </form>
286
+ );
287
+ }
288
+
289
+ function EmbedHistory({
290
+ activeSessionId,
291
+ chatSessions,
292
+ isProcessing,
293
+ onDeleteSession,
294
+ onOpenSession,
295
+ }: {
296
+ activeSessionId?: string | null;
297
+ chatSessions: ChatSessionResponse[];
298
+ isProcessing: boolean;
299
+ onDeleteSession: (sessionId: string) => void;
300
+ onOpenSession: (sessionId: string) => void;
301
+ }) {
302
+ return (
303
+ <section className="embed-history">
304
+ {chatSessions.length === 0 ? (
305
+ <p className="embed-empty-copy">Belum ada riwayat chat.</p>
306
+ ) : (
307
+ chatSessions.map((session) => (
308
+ <button
309
+ className="embed-history-item"
310
+ data-active={session.uuid_id === activeSessionId}
311
+ key={session.uuid_id}
312
+ onClick={() => onOpenSession(session.uuid_id)}
313
+ type="button"
314
+ >
315
+ <span>
316
+ <strong>{session.title}</strong>
317
+ <small>
318
+ {formatMessageTime(
319
+ session.last_message_at ??
320
+ session.updated_at ??
321
+ session.created_at,
322
+ )}
323
+ </small>
324
+ </span>
325
+
326
+ <span
327
+ aria-disabled={isProcessing}
328
+ aria-label="Hapus sesi"
329
+ className="embed-history-delete"
330
+ onClick={(event) => {
331
+ event.stopPropagation();
332
+ onDeleteSession(session.uuid_id);
333
+ }}
334
+ role="button"
335
+ tabIndex={0}
336
+ >
337
+ <Trash2 className="size-4" />
338
+ </span>
339
+ </button>
340
+ ))
341
+ )}
342
+ </section>
343
+ );
344
+ }
345
+
346
+ export default function Embed({
347
+ backendMessage: initialBackendMessage,
348
+ chatSessions,
349
+ config,
350
+ courses,
351
+ currentSession,
352
+ isAuthorized,
353
+ isDirectChatMode = false,
354
+ messages: initialMessages,
355
+ pendingInitialChat,
356
+ selectedCourseId: initialSelectedCourseId,
357
+ sessionId,
358
+ }: EmbedProps) {
359
+ const pendingInitialChatRef = useRef(pendingInitialChat ?? undefined);
360
+ const hasStartedPendingInitialChatRef = useRef(false);
361
+ const isWaitingForDirectAssistantRef = useRef(false);
362
+ const knownMessageIdsRef = useRef(
363
+ new Set(initialMessages.map((message) => message.uuid_id)),
364
+ );
365
+ const [pendingUserMessage] = useState<ChatMessageResponse | undefined>(
366
+ pendingInitialChat
367
+ ? createLocalUserMessage(pendingInitialChat.content)
368
+ : undefined,
369
+ );
370
+ const pendingUserMessageRef = useRef(pendingUserMessage);
371
+ const [activeTab, setActiveTab] = useState<'chat' | 'history'>('chat');
372
+ const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
373
+ useState<string>();
374
+ const [isProcessing, setIsProcessing] = useState(
375
+ Boolean(pendingInitialChat),
376
+ );
377
+ const [localBackendMessage, setLocalBackendMessage] = useState<string>();
378
+ const [optimisticMessages, setOptimisticMessages] = useState<
379
+ ChatMessageResponse[]
380
+ >(() => (pendingUserMessage ? [pendingUserMessage] : []));
381
+ const [question, setQuestion] = useState('');
382
+ const [selectedCourseId, setSelectedCourseId] = useState(
383
+ initialSelectedCourseId ??
384
+ config.courseId ??
385
+ (courses[0]?.id === undefined ? '' : String(courses[0].id)),
386
+ );
387
+ const messagesEndRef = useRef<HTMLDivElement>(null);
388
+ const backendMessage = localBackendMessage ?? initialBackendMessage;
389
+ const messages = useMemo(
390
+ () => appendUniqueMessages(initialMessages, optimisticMessages),
391
+ [initialMessages, optimisticMessages],
392
+ );
393
+ const rootStyle = {
394
+ '--embed-bg': config.background,
395
+ '--embed-border': config.border,
396
+ '--embed-danger': config.danger,
397
+ '--embed-muted': config.muted,
398
+ '--embed-primary': config.primary,
399
+ '--embed-primary-soft': config.primarySoft,
400
+ '--embed-radius': config.radius,
401
+ '--embed-surface': config.surface,
402
+ '--embed-text': config.text,
403
+ } as CSSProperties;
404
+
405
+ const sendMessage = useCallback(
406
+ (
407
+ content: string,
408
+ courseId: string,
409
+ optimisticUserMessage?: ChatMessageResponse,
410
+ ): void => {
411
+ if (!sessionId) {
412
+ return;
413
+ }
414
+
415
+ setLocalBackendMessage(undefined);
416
+
417
+ router.post(
418
+ configuredUrl(storeEmbedMessage.url(sessionId)),
419
+ {
420
+ content,
421
+ course_id: courseId,
422
+ },
423
+ {
424
+ onError: (errors) => {
425
+ isWaitingForDirectAssistantRef.current = false;
426
+
427
+ if (optimisticUserMessage) {
428
+ setOptimisticMessages((currentMessages) =>
429
+ currentMessages.filter(
430
+ (message) =>
431
+ message.uuid_id !==
432
+ optimisticUserMessage.uuid_id,
433
+ ),
434
+ );
435
+ }
436
+
437
+ pendingInitialChatRef.current = undefined;
438
+ setQuestion(content);
439
+ setLocalBackendMessage(firstError(errors));
440
+ },
441
+ onFinish: () => setIsProcessing(false),
442
+ onStart: () => {
443
+ isWaitingForDirectAssistantRef.current =
444
+ isDirectChatMode;
445
+ setIsProcessing(true);
446
+ },
447
+ onSuccess: () => {
448
+ pendingInitialChatRef.current = undefined;
449
+ setOptimisticMessages([]);
450
+ setQuestion('');
451
+ },
452
+ preserveScroll: true,
453
+ },
454
+ );
455
+ },
456
+ [isDirectChatMode, sessionId],
457
+ );
458
+
459
+ useEffect(() => {
460
+ if (isDirectChatMode && isWaitingForDirectAssistantRef.current) {
461
+ const newAssistantMessage = messages.find(
462
+ (message) =>
463
+ !knownMessageIdsRef.current.has(message.uuid_id) &&
464
+ !isUserMessage(message.role),
465
+ );
466
+
467
+ if (newAssistantMessage) {
468
+ setAnimatedAssistantMessageId(newAssistantMessage.uuid_id);
469
+ isWaitingForDirectAssistantRef.current = false;
470
+ }
471
+ }
472
+
473
+ for (const message of messages) {
474
+ knownMessageIdsRef.current.add(message.uuid_id);
475
+ }
476
+ }, [isDirectChatMode, messages]);
477
+
478
+ useEffect(() => {
479
+ const scrollFrame = window.requestAnimationFrame(() => {
480
+ messagesEndRef.current?.scrollIntoView({ block: 'end' });
481
+ });
482
+
483
+ return () => window.cancelAnimationFrame(scrollFrame);
484
+ }, [messages, isProcessing]);
485
+
486
+ useEffect(() => {
487
+ const pendingInitialChat = pendingInitialChatRef.current;
488
+
489
+ if (!pendingInitialChat || hasStartedPendingInitialChatRef.current) {
490
+ return;
491
+ }
492
+
493
+ hasStartedPendingInitialChatRef.current = true;
494
+ const pendingMessage = pendingUserMessageRef.current;
495
+ const timeoutId = window.setTimeout(() => {
496
+ sendMessage(
497
+ pendingInitialChat.content,
498
+ pendingInitialChat.courseId,
499
+ pendingMessage,
500
+ );
501
+ }, 0);
502
+
503
+ return () => window.clearTimeout(timeoutId);
504
+ }, [sendMessage]);
505
+
506
+ function handleQuestionSubmit(event: FormEvent<HTMLFormElement>): void {
507
+ event.preventDefault();
508
+
509
+ const trimmedQuestion = question.trim();
510
+
511
+ if (!selectedCourseId || !trimmedQuestion) {
512
+ return;
513
+ }
514
+
515
+ if (!sessionId) {
516
+ setLocalBackendMessage(undefined);
517
+ router.post(
518
+ configuredUrl(storeEmbedSession.url()),
519
+ {
520
+ course_id: selectedCourseId,
521
+ title: trimmedQuestion,
522
+ },
523
+ {
524
+ onError: (errors) => {
525
+ setLocalBackendMessage(firstError(errors));
526
+ },
527
+ onFinish: () => setIsProcessing(false),
528
+ onStart: () => setIsProcessing(true),
529
+ preserveScroll: true,
530
+ },
531
+ );
532
+
533
+ return;
534
+ }
535
+
536
+ const userMessage = createLocalUserMessage(trimmedQuestion);
537
+ setQuestion('');
538
+ setOptimisticMessages((currentMessages) => [
539
+ ...currentMessages,
540
+ userMessage,
541
+ ]);
542
+ sendMessage(trimmedQuestion, selectedCourseId, userMessage);
543
+ }
544
+
545
+ function handleNewSession(): void {
546
+ setActiveTab('chat');
547
+ setOptimisticMessages([]);
548
+ setQuestion('');
549
+ router.visit(configuredUrl(embedRoute.url(), { session_id: null }));
550
+ }
551
+
552
+ function handleOpenSession(targetSessionId: string): void {
553
+ setActiveTab('chat');
554
+ router.visit(
555
+ configuredUrl(embedRoute.url(), { session_id: targetSessionId }),
556
+ );
557
+ }
558
+
559
+ function handleDeleteSession(targetSessionId: string): void {
560
+ if (!window.confirm('Hapus riwayat chat ini?')) {
561
+ return;
562
+ }
563
+
564
+ router.delete(configuredUrl(destroyEmbedSession.url(targetSessionId)), {
565
+ onError: (errors) => {
566
+ setLocalBackendMessage(firstError(errors));
567
+ },
568
+ preserveScroll: true,
569
+ });
570
+ }
571
+
572
+ function handleClose(): void {
573
+ window.parent?.postMessage({ type: 'sevima-raghub:close' }, '*');
574
+ }
575
+
576
+ return (
577
+ <>
578
+ <Head title={config.title} />
579
+
580
+ <main className="embed-shell" style={rootStyle}>
581
+ <header className="embed-toolbar">
582
+ <div className="embed-tabs" role="tablist">
583
+ <button
584
+ aria-selected={activeTab === 'chat'}
585
+ onClick={() => setActiveTab('chat')}
586
+ role="tab"
587
+ type="button"
588
+ >
589
+ Chat
590
+ </button>
591
+ <button
592
+ aria-selected={activeTab === 'history'}
593
+ onClick={() => setActiveTab('history')}
594
+ role="tab"
595
+ type="button"
596
+ >
597
+ Riwayat
598
+ </button>
599
+ </div>
600
+
601
+ <div className="embed-actions">
602
+ <button
603
+ aria-label="Sesi baru"
604
+ className="embed-icon-button embed-icon-button--primary"
605
+ onClick={handleNewSession}
606
+ type="button"
607
+ >
608
+ <Plus className="size-6" />
609
+ </button>
610
+ <button
611
+ aria-label="Tutup iframe"
612
+ className="embed-icon-button"
613
+ onClick={handleClose}
614
+ type="button"
615
+ >
616
+ <X className="size-6" />
617
+ </button>
618
+ </div>
619
+ </header>
620
+
621
+ {activeTab === 'history' ? (
622
+ <EmbedHistory
623
+ activeSessionId={sessionId}
624
+ chatSessions={chatSessions}
625
+ isProcessing={isProcessing}
626
+ onDeleteSession={handleDeleteSession}
627
+ onOpenSession={handleOpenSession}
628
+ />
629
+ ) : (
630
+ <section
631
+ className={
632
+ sessionId
633
+ ? 'embed-chat embed-chat--session'
634
+ : 'embed-chat'
635
+ }
636
+ >
637
+ {!isAuthorized ? (
638
+ <div className="embed-empty-state">
639
+ <span className="embed-empty-icon">
640
+ <MessageSquare className="size-7" />
641
+ </span>
642
+ <h1>{config.title}</h1>
643
+ <p>{backendMessage}</p>
644
+ </div>
645
+ ) : sessionId ? (
646
+ <div className="embed-thread">
647
+ {backendMessage && (
648
+ <p className="embed-status">
649
+ {backendMessage}
650
+ </p>
651
+ )}
652
+
653
+ {messages.map((message) => (
654
+ <EmbedMessage
655
+ key={message.uuid_id}
656
+ message={message}
657
+ shouldAnimateTyping={
658
+ message.uuid_id ===
659
+ animatedAssistantMessageId
660
+ }
661
+ />
662
+ ))}
663
+
664
+ {isProcessing && (
665
+ <article className="embed-message embed-message--assistant">
666
+ <span className="embed-message-avatar embed-message-avatar--assistant">
667
+ <Bot className="size-4" />
668
+ </span>
669
+ <div className="embed-message-bubble embed-message-bubble--typing">
670
+ <LoadingIndicator
671
+ label="Thinking"
672
+ showSpinner={false}
673
+ />
674
+ </div>
675
+ </article>
676
+ )}
677
+
678
+ <div ref={messagesEndRef} />
679
+ </div>
680
+ ) : (
681
+ <div className="embed-empty-state">
682
+ <span className="embed-empty-icon">
683
+ <MessageSquare className="size-7" />
684
+ </span>
685
+ <h1>Good Morning, {config.userName}</h1>
686
+ <p>{config.subtitle}</p>
687
+ {backendMessage && (
688
+ <p className="embed-status">
689
+ {backendMessage}
690
+ </p>
691
+ )}
692
+ </div>
693
+ )}
694
+
695
+ {isAuthorized && (
696
+ <div className="embed-composer-dock">
697
+ <EmbedComposer
698
+ courses={courses}
699
+ isProcessing={isProcessing}
700
+ onCourseChange={setSelectedCourseId}
701
+ onQuestionChange={setQuestion}
702
+ onSubmit={handleQuestionSubmit}
703
+ question={question}
704
+ selectedCourseId={selectedCourseId}
705
+ />
706
+ {currentSession?.last_message_at && (
707
+ <p className="embed-updated-at">
708
+ Terakhir diperbarui{' '}
709
+ {formatMessageTime(
710
+ currentSession.last_message_at,
711
+ )}
712
+ </p>
713
+ )}
714
+ </div>
715
+ )}
716
+ </section>
717
+ )}
718
+ </main>
719
+ </>
720
+ );
721
+ }
resources/js/pages/mahasiswa-chat.tsx CHANGED
@@ -30,6 +30,7 @@ type MahasiswaChatProps = {
30
  chatSessions: ChatSessionResponse[];
31
  courses: CourseResponse[];
32
  currentSession?: ChatSessionResponse | null;
 
33
  messages: ChatMessageResponse[];
34
  pendingInitialChat?: PendingInitialChat | null;
35
  selectedCourseId?: string | null;
@@ -61,7 +62,42 @@ function formatMessageTime(value: string): string {
61
  }).format(date);
62
  }
63
 
64
- function ChatMessage({ message }: { message: ChatMessageResponse }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  const isUser = isUserMessage(message.role);
66
 
67
  return (
@@ -79,7 +115,13 @@ function ChatMessage({ message }: { message: ChatMessageResponse }) {
79
  )}
80
 
81
  <div className="student-message-bubble">
82
- <p className="student-message-content">{message.content}</p>
 
 
 
 
 
 
83
  <span className="student-message-time">
84
  {formatMessageTime(message.created_at)}
85
  </span>
@@ -126,13 +168,18 @@ export default function MahasiswaChat({
126
  chatSessions,
127
  courses,
128
  currentSession,
 
129
  messages: initialMessages,
130
  pendingInitialChat,
131
  selectedCourseId: initialSelectedCourseId,
132
  sessionId,
133
  }: MahasiswaChatProps) {
134
  const pendingInitialChatRef = useRef(pendingInitialChat ?? undefined);
 
135
  const hasStartedPendingInitialChatRef = useRef(false);
 
 
 
136
  const [pendingUserMessage] = useState<ChatMessageResponse | undefined>(
137
  pendingInitialChat
138
  ? createLocalUserMessage(pendingInitialChat.content)
@@ -140,6 +187,8 @@ export default function MahasiswaChat({
140
  );
141
  const pendingUserMessageRef = useRef(pendingUserMessage);
142
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
 
 
143
  const [isProcessing, setIsProcessing] = useState(
144
  Boolean(pendingInitialChat),
145
  );
@@ -179,6 +228,25 @@ export default function MahasiswaChat({
179
  ];
180
  }, [courses, currentSession, selectedCourseId]);
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  useEffect(() => {
183
  const scrollFrame = window.requestAnimationFrame(() => {
184
  messagesEndRef.current?.scrollIntoView({
@@ -205,6 +273,8 @@ export default function MahasiswaChat({
205
  },
206
  {
207
  onError: (errors) => {
 
 
208
  if (optimisticUserMessage) {
209
  setOptimisticMessages((currentMessages) =>
210
  currentMessages.filter(
@@ -220,7 +290,11 @@ export default function MahasiswaChat({
220
  setLocalBackendMessage(firstError(errors));
221
  },
222
  onFinish: () => setIsProcessing(false),
223
- onStart: () => setIsProcessing(true),
 
 
 
 
224
  onSuccess: () => {
225
  pendingInitialChatRef.current = undefined;
226
  setOptimisticMessages([]);
@@ -230,7 +304,7 @@ export default function MahasiswaChat({
230
  },
231
  );
232
  },
233
- [sessionId],
234
  );
235
 
236
  useEffect(() => {
@@ -312,6 +386,10 @@ export default function MahasiswaChat({
312
  <ChatMessage
313
  key={message.uuid_id}
314
  message={message}
 
 
 
 
315
  />
316
  ))}
317
 
 
30
  chatSessions: ChatSessionResponse[];
31
  courses: CourseResponse[];
32
  currentSession?: ChatSessionResponse | null;
33
+ isDirectChatMode?: boolean;
34
  messages: ChatMessageResponse[];
35
  pendingInitialChat?: PendingInitialChat | null;
36
  selectedCourseId?: string | null;
 
62
  }).format(date);
63
  }
64
 
65
+ function TypingText({ text }: { text: string }) {
66
+ const [visibleLength, setVisibleLength] = useState(0);
67
+
68
+ useEffect(() => {
69
+ const intervalId = window.setInterval(() => {
70
+ setVisibleLength((currentLength) => {
71
+ if (currentLength >= text.length) {
72
+ window.clearInterval(intervalId);
73
+
74
+ return currentLength;
75
+ }
76
+
77
+ return currentLength + 1;
78
+ });
79
+ }, 16);
80
+
81
+ return () => window.clearInterval(intervalId);
82
+ }, [text]);
83
+
84
+ return (
85
+ <>
86
+ {text.slice(0, visibleLength)}
87
+ {visibleLength < text.length && (
88
+ <span className="student-message-caret" aria-hidden />
89
+ )}
90
+ </>
91
+ );
92
+ }
93
+
94
+ function ChatMessage({
95
+ message,
96
+ shouldAnimateTyping = false,
97
+ }: {
98
+ message: ChatMessageResponse;
99
+ shouldAnimateTyping?: boolean;
100
+ }) {
101
  const isUser = isUserMessage(message.role);
102
 
103
  return (
 
115
  )}
116
 
117
  <div className="student-message-bubble">
118
+ <p className="student-message-content">
119
+ {!isUser && shouldAnimateTyping ? (
120
+ <TypingText key={message.uuid_id} text={message.content} />
121
+ ) : (
122
+ message.content
123
+ )}
124
+ </p>
125
  <span className="student-message-time">
126
  {formatMessageTime(message.created_at)}
127
  </span>
 
168
  chatSessions,
169
  courses,
170
  currentSession,
171
+ isDirectChatMode = false,
172
  messages: initialMessages,
173
  pendingInitialChat,
174
  selectedCourseId: initialSelectedCourseId,
175
  sessionId,
176
  }: MahasiswaChatProps) {
177
  const pendingInitialChatRef = useRef(pendingInitialChat ?? undefined);
178
+ const isWaitingForDirectAssistantRef = useRef(false);
179
  const hasStartedPendingInitialChatRef = useRef(false);
180
+ const knownMessageIdsRef = useRef(
181
+ new Set(initialMessages.map((message) => message.uuid_id)),
182
+ );
183
  const [pendingUserMessage] = useState<ChatMessageResponse | undefined>(
184
  pendingInitialChat
185
  ? createLocalUserMessage(pendingInitialChat.content)
 
187
  );
188
  const pendingUserMessageRef = useRef(pendingUserMessage);
189
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
190
+ const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
191
+ useState<string>();
192
  const [isProcessing, setIsProcessing] = useState(
193
  Boolean(pendingInitialChat),
194
  );
 
228
  ];
229
  }, [courses, currentSession, selectedCourseId]);
230
 
231
+ useEffect(() => {
232
+ if (isDirectChatMode && isWaitingForDirectAssistantRef.current) {
233
+ const newAssistantMessage = messages.find(
234
+ (message) =>
235
+ !knownMessageIdsRef.current.has(message.uuid_id) &&
236
+ !isUserMessage(message.role),
237
+ );
238
+
239
+ if (newAssistantMessage) {
240
+ setAnimatedAssistantMessageId(newAssistantMessage.uuid_id);
241
+ isWaitingForDirectAssistantRef.current = false;
242
+ }
243
+ }
244
+
245
+ for (const message of messages) {
246
+ knownMessageIdsRef.current.add(message.uuid_id);
247
+ }
248
+ }, [isDirectChatMode, messages]);
249
+
250
  useEffect(() => {
251
  const scrollFrame = window.requestAnimationFrame(() => {
252
  messagesEndRef.current?.scrollIntoView({
 
273
  },
274
  {
275
  onError: (errors) => {
276
+ isWaitingForDirectAssistantRef.current = false;
277
+
278
  if (optimisticUserMessage) {
279
  setOptimisticMessages((currentMessages) =>
280
  currentMessages.filter(
 
290
  setLocalBackendMessage(firstError(errors));
291
  },
292
  onFinish: () => setIsProcessing(false),
293
+ onStart: () => {
294
+ isWaitingForDirectAssistantRef.current =
295
+ isDirectChatMode;
296
+ setIsProcessing(true);
297
+ },
298
  onSuccess: () => {
299
  pendingInitialChatRef.current = undefined;
300
  setOptimisticMessages([]);
 
304
  },
305
  );
306
  },
307
+ [isDirectChatMode, sessionId],
308
  );
309
 
310
  useEffect(() => {
 
386
  <ChatMessage
387
  key={message.uuid_id}
388
  message={message}
389
+ shouldAnimateTyping={
390
+ message.uuid_id ===
391
+ animatedAssistantMessageId
392
+ }
393
  />
394
  ))}
395
 
resources/js/pages/settings/profile.tsx CHANGED
@@ -96,7 +96,7 @@ export default function Profile({
96
 
97
  {status ===
98
  'verification-link-sent' && (
99
- <div className="mt-2 text-sm font-medium text-green-600">
100
  A new verification link has been
101
  sent to your email address.
102
  </div>
 
96
 
97
  {status ===
98
  'verification-link-sent' && (
99
+ <div className="mt-2 text-sm font-medium text-primary">
100
  A new verification link has been
101
  sent to your email address.
102
  </div>
routes/web.php CHANGED
@@ -1,7 +1,9 @@
1
  <?php
2
 
 
3
  use App\Http\Controllers\AuthController;
4
  use App\Http\Controllers\DosenController;
 
5
  use App\Http\Controllers\MahasiswaController;
6
  use Illuminate\Support\Facades\Route;
7
  use Laravel\Fortify\Features;
@@ -20,28 +22,50 @@
20
 
21
  Route::post('auth/register', [AuthController::class, 'register'])->name('auth.register.store');
22
 
23
- Route::get('mahasiswa', [MahasiswaController::class, 'index'])->name('mahasiswa');
24
 
25
- Route::post('mahasiswa/sessions', [MahasiswaController::class, 'storeSession'])->name('mahasiswa.sessions.store');
26
 
27
- Route::get('mahasiswa/{sessionId}', [MahasiswaController::class, 'show'])->name('mahasiswa.show');
28
 
29
- Route::post('mahasiswa/{sessionId}/messages', [MahasiswaController::class, 'storeMessage'])->name('mahasiswa.messages.store');
30
 
31
- Route::delete('mahasiswa/{sessionId}', [MahasiswaController::class, 'destroySession'])->name('mahasiswa.destroy');
32
 
33
- Route::get('dosen', [DosenController::class, 'index'])->name('dosen');
34
 
35
- Route::post('dosen/courses', [DosenController::class, 'storeCourse'])->name('dosen.courses.store');
 
36
 
37
- Route::inertia('dosen/pengaturan', 'dosen/pengaturan')->name('dosen.pengaturan');
 
38
 
39
- Route::post('dosen/{courseId}/documents', [DosenController::class, 'uploadDocument'])->name('dosen.documents.store');
 
40
 
41
- Route::get('dosen/{courseId}', [DosenController::class, 'show'])->name('dosen.show');
42
 
43
- Route::put('dosen/{courseId}', [DosenController::class, 'updateCourse'])->name('dosen.update');
44
 
45
- Route::delete('dosen/{courseId}', [DosenController::class, 'destroyCourse'])->name('dosen.destroy');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
  require __DIR__.'/settings.php';
 
1
  <?php
2
 
3
+ use App\Http\Controllers\AdminController;
4
  use App\Http\Controllers\AuthController;
5
  use App\Http\Controllers\DosenController;
6
+ use App\Http\Controllers\EmbedController;
7
  use App\Http\Controllers\MahasiswaController;
8
  use Illuminate\Support\Facades\Route;
9
  use Laravel\Fortify\Features;
 
22
 
23
  Route::post('auth/register', [AuthController::class, 'register'])->name('auth.register.store');
24
 
25
+ Route::get('embed', [EmbedController::class, 'index'])->name('embed');
26
 
27
+ Route::post('embed/sessions', [EmbedController::class, 'storeSession'])->name('embed.sessions.store');
28
 
29
+ Route::post('embed/{sessionId}/messages', [EmbedController::class, 'storeMessage'])->name('embed.messages.store');
30
 
31
+ Route::delete('embed/{sessionId}', [EmbedController::class, 'destroySession'])->name('embed.destroy');
32
 
33
+ Route::get('admin/login', [AuthController::class, 'showAdminLogin'])->name('admin.login');
34
 
35
+ Route::post('admin/login', [AuthController::class, 'adminLogin'])->name('admin.login.store');
36
 
37
+ Route::middleware('rag.auth:admin')->prefix('admin')->name('admin.')->group(function (): void {
38
+ Route::get('/', [AdminController::class, 'index'])->name('dashboard');
39
 
40
+ Route::get('pengaturan', [AdminController::class, 'settings'])->name('pengaturan');
41
+ });
42
 
43
+ Route::middleware('rag.auth:student')->group(function (): void {
44
+ Route::get('mahasiswa', [MahasiswaController::class, 'index'])->name('mahasiswa');
45
 
46
+ Route::post('mahasiswa/sessions', [MahasiswaController::class, 'storeSession'])->name('mahasiswa.sessions.store');
47
 
48
+ Route::get('mahasiswa/{sessionId}', [MahasiswaController::class, 'show'])->name('mahasiswa.show');
49
 
50
+ Route::post('mahasiswa/{sessionId}/messages', [MahasiswaController::class, 'storeMessage'])->name('mahasiswa.messages.store');
51
+
52
+ Route::delete('mahasiswa/{sessionId}', [MahasiswaController::class, 'destroySession'])->name('mahasiswa.destroy');
53
+ });
54
+
55
+ Route::middleware('rag.auth:lecturer')->group(function (): void {
56
+ Route::get('dosen', [DosenController::class, 'index'])->name('dosen');
57
+
58
+ Route::post('dosen/courses', [DosenController::class, 'storeCourse'])->name('dosen.courses.store');
59
+
60
+ Route::inertia('dosen/pengaturan', 'dosen/pengaturan')->name('dosen.pengaturan');
61
+
62
+ Route::post('dosen/{courseId}/documents', [DosenController::class, 'uploadDocument'])->name('dosen.documents.store');
63
+
64
+ Route::get('dosen/{courseId}', [DosenController::class, 'show'])->name('dosen.show');
65
+
66
+ Route::put('dosen/{courseId}', [DosenController::class, 'updateCourse'])->name('dosen.update');
67
+
68
+ Route::delete('dosen/{courseId}', [DosenController::class, 'destroyCourse'])->name('dosen.destroy');
69
+ });
70
 
71
  require __DIR__.'/settings.php';
tests/Feature/AuthProxyTest.php CHANGED
@@ -31,6 +31,7 @@
31
  ->assertRedirect(route('mahasiswa', absolute: false))
32
  ->assertCookie('sevima_raghub_auth_token')
33
  ->assertCookie('sevima_raghub_auth_user')
 
34
  ->assertSessionHasNoErrors();
35
 
36
  Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
@@ -84,7 +85,7 @@
84
  'password' => 'secret123',
85
  'role' => 'student',
86
  ])
87
- ->assertRedirect(route('register.mahasiswa'))
88
  ->assertSessionHas('status', 'Registration successful.')
89
  ->assertSessionHasNoErrors();
90
 
 
31
  ->assertRedirect(route('mahasiswa', absolute: false))
32
  ->assertCookie('sevima_raghub_auth_token')
33
  ->assertCookie('sevima_raghub_auth_user')
34
+ ->assertSessionHas('status', 'Login berhasil.')
35
  ->assertSessionHasNoErrors();
36
 
37
  Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
 
85
  'password' => 'secret123',
86
  'role' => 'student',
87
  ])
88
+ ->assertRedirect(route('login'))
89
  ->assertSessionHas('status', 'Registration successful.')
90
  ->assertSessionHasNoErrors();
91
 
tests/Feature/DosenDashboardTest.php CHANGED
@@ -5,6 +5,23 @@
5
  use Illuminate\Support\Facades\Http;
6
  use Inertia\Testing\AssertableInertia as Assert;
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  test('dosen dashboard page can be rendered', function () {
9
  config(['services.rag.base_url' => 'https://rag.test/api/v1']);
10
 
@@ -39,6 +56,7 @@
39
  ]);
40
 
41
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
42
  ->get(route('dosen'))
43
  ->assertOk()
44
  ->assertInertia(fn (Assert $page) => $page
@@ -50,10 +68,16 @@
50
  ->where('knowledgeBases.0.documentCount', 1)
51
  ->where('knowledgeBases.0.failedDocumentCount', 0)
52
  ->where('knowledgeBases.0.updatedAt', '2026-05-02'));
 
 
 
 
53
  });
54
 
55
  test('dosen settings page can be rendered', function () {
56
- $this->get(route('dosen.pengaturan'))
 
 
57
  ->assertOk()
58
  ->assertInertia(fn (Assert $page) => $page->component('dosen/pengaturan'));
59
  });
@@ -85,6 +109,7 @@
85
  ]);
86
 
87
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
88
  ->get(route('dosen.show', ['courseId' => '101']))
89
  ->assertOk()
90
  ->assertInertia(fn (Assert $page) => $page
@@ -118,6 +143,7 @@
118
  ]);
119
 
120
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
121
  ->from(route('dosen'))
122
  ->post(route('dosen.courses.store'), [
123
  'description' => 'Materi dasar algoritma',
@@ -142,7 +168,9 @@
142
  ], 422),
143
  ]);
144
 
145
- $this->from(route('dosen'))
 
 
146
  ->post(route('dosen.courses.store'), [
147
  'title' => 'Algoritma & Pemrograman',
148
  ])
@@ -162,6 +190,7 @@
162
  ]);
163
 
164
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
165
  ->from(route('dosen.show', ['courseId' => '101']))
166
  ->put(route('dosen.update', ['courseId' => '101']), [
167
  'description' => 'Materi semester awal',
@@ -186,7 +215,9 @@
186
  ], 422),
187
  ]);
188
 
189
- $this->from(route('dosen.show', ['courseId' => '101']))
 
 
190
  ->put(route('dosen.update', ['courseId' => '101']), [
191
  'title' => 'Algoritma Lanjut',
192
  ])
@@ -202,6 +233,7 @@
202
  ]);
203
 
204
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
205
  ->from(route('dosen.show', ['courseId' => '101']))
206
  ->delete(route('dosen.destroy', ['courseId' => '101']))
207
  ->assertRedirect(route('dosen'))
@@ -221,7 +253,9 @@
221
  ], 422),
222
  ]);
223
 
224
- $this->from(route('dosen.show', ['courseId' => '101']))
 
 
225
  ->delete(route('dosen.destroy', ['courseId' => '101']))
226
  ->assertRedirect(route('dosen.show', ['courseId' => '101']))
227
  ->assertSessionHasErrors(['course' => 'Course still has active documents.']);
@@ -242,6 +276,7 @@
242
  $file = UploadedFile::fake()->create('materi.pdf', 100, 'application/pdf');
243
 
244
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
245
  ->from(route('dosen.show', ['courseId' => '101']))
246
  ->post(route('dosen.documents.store', ['courseId' => '101']), [
247
  'file' => $file,
@@ -273,7 +308,9 @@
273
 
274
  $file = UploadedFile::fake()->create('materi.pdf', 100, 'application/pdf');
275
 
276
- $this->from(route('dosen.show', ['courseId' => '101']))
 
 
277
  ->post(route('dosen.documents.store', ['courseId' => '101']), [
278
  'file' => $file,
279
  ])
 
5
  use Illuminate\Support\Facades\Http;
6
  use Inertia\Testing\AssertableInertia as Assert;
7
 
8
+ function lecturerAuthUserCookie(): string
9
+ {
10
+ return json_encode([
11
+ 'email' => 'lecturer@example.com',
12
+ 'id' => 10,
13
+ 'identity_number' => 'D12345',
14
+ 'name' => 'Dosen',
15
+ 'role' => 'lecturer',
16
+ 'status' => 'active',
17
+ ], JSON_THROW_ON_ERROR);
18
+ }
19
+
20
+ test('dosen routes redirect guests to login', function () {
21
+ $this->get(route('dosen'))
22
+ ->assertRedirect(route('login'));
23
+ });
24
+
25
  test('dosen dashboard page can be rendered', function () {
26
  config(['services.rag.base_url' => 'https://rag.test/api/v1']);
27
 
 
56
  ]);
57
 
58
  $this->withCookie('sevima_raghub_auth_token', 'token')
59
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
60
  ->get(route('dosen'))
61
  ->assertOk()
62
  ->assertInertia(fn (Assert $page) => $page
 
68
  ->where('knowledgeBases.0.documentCount', 1)
69
  ->where('knowledgeBases.0.failedDocumentCount', 0)
70
  ->where('knowledgeBases.0.updatedAt', '2026-05-02'));
71
+
72
+ Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'GET'
73
+ && str_starts_with($request->url(), 'https://rag.test/api/v1/courses?')
74
+ && $request->hasHeader('Authorization', 'Bearer token'));
75
  });
76
 
77
  test('dosen settings page can be rendered', function () {
78
+ $this->withCookie('sevima_raghub_auth_token', 'token')
79
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
80
+ ->get(route('dosen.pengaturan'))
81
  ->assertOk()
82
  ->assertInertia(fn (Assert $page) => $page->component('dosen/pengaturan'));
83
  });
 
109
  ]);
110
 
111
  $this->withCookie('sevima_raghub_auth_token', 'token')
112
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
113
  ->get(route('dosen.show', ['courseId' => '101']))
114
  ->assertOk()
115
  ->assertInertia(fn (Assert $page) => $page
 
143
  ]);
144
 
145
  $this->withCookie('sevima_raghub_auth_token', 'token')
146
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
147
  ->from(route('dosen'))
148
  ->post(route('dosen.courses.store'), [
149
  'description' => 'Materi dasar algoritma',
 
168
  ], 422),
169
  ]);
170
 
171
+ $this->withCookie('sevima_raghub_auth_token', 'token')
172
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
173
+ ->from(route('dosen'))
174
  ->post(route('dosen.courses.store'), [
175
  'title' => 'Algoritma & Pemrograman',
176
  ])
 
190
  ]);
191
 
192
  $this->withCookie('sevima_raghub_auth_token', 'token')
193
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
194
  ->from(route('dosen.show', ['courseId' => '101']))
195
  ->put(route('dosen.update', ['courseId' => '101']), [
196
  'description' => 'Materi semester awal',
 
215
  ], 422),
216
  ]);
217
 
218
+ $this->withCookie('sevima_raghub_auth_token', 'token')
219
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
220
+ ->from(route('dosen.show', ['courseId' => '101']))
221
  ->put(route('dosen.update', ['courseId' => '101']), [
222
  'title' => 'Algoritma Lanjut',
223
  ])
 
233
  ]);
234
 
235
  $this->withCookie('sevima_raghub_auth_token', 'token')
236
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
237
  ->from(route('dosen.show', ['courseId' => '101']))
238
  ->delete(route('dosen.destroy', ['courseId' => '101']))
239
  ->assertRedirect(route('dosen'))
 
253
  ], 422),
254
  ]);
255
 
256
+ $this->withCookie('sevima_raghub_auth_token', 'token')
257
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
258
+ ->from(route('dosen.show', ['courseId' => '101']))
259
  ->delete(route('dosen.destroy', ['courseId' => '101']))
260
  ->assertRedirect(route('dosen.show', ['courseId' => '101']))
261
  ->assertSessionHasErrors(['course' => 'Course still has active documents.']);
 
276
  $file = UploadedFile::fake()->create('materi.pdf', 100, 'application/pdf');
277
 
278
  $this->withCookie('sevima_raghub_auth_token', 'token')
279
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
280
  ->from(route('dosen.show', ['courseId' => '101']))
281
  ->post(route('dosen.documents.store', ['courseId' => '101']), [
282
  'file' => $file,
 
308
 
309
  $file = UploadedFile::fake()->create('materi.pdf', 100, 'application/pdf');
310
 
311
+ $this->withCookie('sevima_raghub_auth_token', 'token')
312
+ ->withCookie('sevima_raghub_auth_user', lecturerAuthUserCookie())
313
+ ->from(route('dosen.show', ['courseId' => '101']))
314
  ->post(route('dosen.documents.store', ['courseId' => '101']), [
315
  'file' => $file,
316
  ])
tests/Feature/MahasiswaDashboardTest.php CHANGED
@@ -4,6 +4,23 @@
4
  use Illuminate\Support\Facades\Http;
5
  use Inertia\Testing\AssertableInertia as Assert;
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  test('mahasiswa dashboard page can be rendered through laravel proxy', function () {
8
  config(['services.rag.base_url' => 'https://rag.test/api/v1']);
9
 
@@ -42,6 +59,7 @@
42
  ]);
43
 
44
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
45
  ->get(route('mahasiswa'))
46
  ->assertOk()
47
  ->assertInertia(fn (Assert $page) => $page
@@ -76,6 +94,7 @@
76
  ]);
77
 
78
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
79
  ->from(route('mahasiswa'))
80
  ->post(route('mahasiswa.sessions.store'), [
81
  'course_id' => '101',
@@ -134,6 +153,7 @@
134
  ]);
135
 
136
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
137
  ->withSession([
138
  'sevima_raghub_direct_messages.session-1' => [
139
  [
@@ -174,6 +194,7 @@
174
  ]);
175
 
176
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
177
  ->from(route('mahasiswa.show', ['sessionId' => 'session-1']))
178
  ->post(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
179
  'content' => 'Apa itu stack?',
@@ -212,6 +233,7 @@
212
  ]);
213
 
214
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
215
  ->from(route('mahasiswa.show', ['sessionId' => 'session-1']))
216
  ->post(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
217
  'content' => 'Apa itu stack?',
@@ -238,7 +260,9 @@
238
  ], 422),
239
  ]);
240
 
241
- $this->from(route('mahasiswa.show', ['sessionId' => 'session-1']))
 
 
242
  ->post(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
243
  'content' => 'Apa itu stack?',
244
  'course_id' => '101',
@@ -255,6 +279,7 @@
255
  ]);
256
 
257
  $this->withCookie('sevima_raghub_auth_token', 'token')
 
258
  ->withSession([
259
  'sevima_raghub_direct_messages.session-1' => [
260
  [
 
4
  use Illuminate\Support\Facades\Http;
5
  use Inertia\Testing\AssertableInertia as Assert;
6
 
7
+ function studentAuthUserCookie(): string
8
+ {
9
+ return json_encode([
10
+ 'email' => 'student@example.com',
11
+ 'id' => 11,
12
+ 'identity_number' => '123456',
13
+ 'name' => 'Alma',
14
+ 'role' => 'student',
15
+ 'status' => 'active',
16
+ ], JSON_THROW_ON_ERROR);
17
+ }
18
+
19
+ test('mahasiswa routes redirect guests to login', function () {
20
+ $this->get(route('mahasiswa'))
21
+ ->assertRedirect(route('login'));
22
+ });
23
+
24
  test('mahasiswa dashboard page can be rendered through laravel proxy', function () {
25
  config(['services.rag.base_url' => 'https://rag.test/api/v1']);
26
 
 
59
  ]);
60
 
61
  $this->withCookie('sevima_raghub_auth_token', 'token')
62
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
63
  ->get(route('mahasiswa'))
64
  ->assertOk()
65
  ->assertInertia(fn (Assert $page) => $page
 
94
  ]);
95
 
96
  $this->withCookie('sevima_raghub_auth_token', 'token')
97
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
98
  ->from(route('mahasiswa'))
99
  ->post(route('mahasiswa.sessions.store'), [
100
  'course_id' => '101',
 
153
  ]);
154
 
155
  $this->withCookie('sevima_raghub_auth_token', 'token')
156
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
157
  ->withSession([
158
  'sevima_raghub_direct_messages.session-1' => [
159
  [
 
194
  ]);
195
 
196
  $this->withCookie('sevima_raghub_auth_token', 'token')
197
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
198
  ->from(route('mahasiswa.show', ['sessionId' => 'session-1']))
199
  ->post(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
200
  'content' => 'Apa itu stack?',
 
233
  ]);
234
 
235
  $this->withCookie('sevima_raghub_auth_token', 'token')
236
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
237
  ->from(route('mahasiswa.show', ['sessionId' => 'session-1']))
238
  ->post(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
239
  'content' => 'Apa itu stack?',
 
260
  ], 422),
261
  ]);
262
 
263
+ $this->withCookie('sevima_raghub_auth_token', 'token')
264
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
265
+ ->from(route('mahasiswa.show', ['sessionId' => 'session-1']))
266
  ->post(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
267
  'content' => 'Apa itu stack?',
268
  'course_id' => '101',
 
279
  ]);
280
 
281
  $this->withCookie('sevima_raghub_auth_token', 'token')
282
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
283
  ->withSession([
284
  'sevima_raghub_direct_messages.session-1' => [
285
  [