diff --git a/.claude/rules/auth.md b/.claude/rules/auth.md
index 569103cb8..6fc8c9b5b 100644
--- a/.claude/rules/auth.md
+++ b/.claude/rules/auth.md
@@ -3,7 +3,7 @@ description: Authentication rules
paths:
- 'src/lib/server/auth.ts'
- 'src/routes/(auth)/**'
- - 'src/routes/(admin)/_utils/auth.ts'
+ - 'src/features/auth/**'
- 'src/routes/(admin)/**'
- 'src/hooks.server.ts'
---
@@ -32,13 +32,26 @@ paths:
- `src/lib/server/auth.ts`: Lucia configuration
- `src/hooks.server.ts`: Global request handler
-- `src/routes/(admin)/_utils/auth.ts`:
- - `validateAdminAccess(locals)` — for page routes; redirects to `/login` for both unauthenticated and non-admin users (do not use in `+server.ts`)
+- `src/features/auth/services/session.ts`:
+ - `getLoggedInUser(locals, url?)` — returns logged-in user or redirects to `/login`
+ - `ensureSessionOrRedirect(locals, url?)` — guard-only; redirects if no session
+- `src/features/auth/services/admin_access.ts`:
+ - `validateAdminAccess(locals, url?)` — for page routes; redirects to `/login` for both unauthenticated and non-admin users (do not use in `+server.ts`)
- `validateAdminAccessForApi(locals)` — for API routes (`+server.ts`); throws `error(401)` if unauthenticated, `error(403)` if not admin
+- `src/features/auth/utils/login.ts`: `buildLoginPath(url?)` — generates `/login` or `/login?redirectTo=...`
+- `src/features/auth/utils/signup.ts`: `buildSignupPath(url?)` — generates `/signup` or `/signup?redirectTo=...` (same signature as `buildLoginPath`)
- `prisma/schema.prisma`: User, Session, Key models
+## Redirect After Login Pattern
+
+After login/signup, redirect users back to their original intended page instead of hardcoding `/`.
+
+- Protected `load()`: pass `url` to `getLoggedInUser(locals, url)` — appends `?redirectTo=` automatically
+- Login/signup actions: validate `redirectTo` with `isSameOriginRedirect()` before using (see `src/lib/utils/url.ts`)
+
## Security
- Never expose session secrets in client code
- Use HTTPS in production
- Validate all user inputs server-side
+- **SvelteKit's `redirect()` does not validate origin** — always validate user-provided redirect destinations with `isSameOriginRedirect()`
diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md
index d19e42138..43cb71b43 100644
--- a/.claude/rules/coding-style.md
+++ b/.claude/rules/coding-style.md
@@ -62,3 +62,12 @@ Delete function only if: (1) zero callers, (2) replacement exists, (3) dependent
- No Prisma imports in route handlers
- Validate input at system boundaries
- Return safe defaults on service errors
+
+## Comments: Why, Not What
+
+Comment the **why** (non-obvious reason), not the **what** (code already says).
+
+- Bad: `// increment counter`
+- Good: `// use task_id as deterministic ID to avoid SSR/hydration mismatch`
+
+Keep to one line. If longer, refactor code (naming, structure) instead.
diff --git a/.claude/rules/sveltekit.md b/.claude/rules/sveltekit.md
index 97232b062..b83ecd119 100644
--- a/.claude/rules/sveltekit.md
+++ b/.claude/rules/sveltekit.md
@@ -12,6 +12,10 @@ paths:
- Page routes: use `redirect()` to navigate
- API routes (`+server.ts`): use `error()` — `redirect()` returns 3xx; `fetch` follows and gets HTML not JSON
+## User-Provided Redirect Destinations
+
+**SvelteKit's `redirect()` does NOT validate origin.** If the redirect destination comes from user input (URL query params, form data), always validate with `isSameOriginRedirect(redirectTo, url.origin)` from `src/lib/utils/url.ts` before redirecting.
+
## Internal Navigation: `resolve()`
All internal navigation must use `resolve()` from `$app/paths`:
@@ -47,6 +51,20 @@ if (!(Object.values(TaskGrade) as string[]).includes(gradeRaw)) {
const grade = gradeRaw as TaskGrade; // Safe now
```
+## Form Actions: `url` Parameter
+
+Form action handlers receive the full `RequestEvent`, including `url`. Destructure it directly:
+
+```typescript
+export const actions = {
+ default: async ({ request, locals, url }) => {
+ const redirectTo = url.searchParams.get('redirectTo');
+ },
+};
+```
+
+`url` is guaranteed non-null. `new URL(request.url)` is an equivalent alternative but unnecessary when `url` is already available.
+
## Page Component Props
`+page.svelte` accepts only `data` and `form` props (`svelte/valid-prop-names-in-kit-pages`).
@@ -81,17 +99,11 @@ Consume with `$derived` in `+page.svelte` to sync after `load()` re-runs.
## Error Handling in load()
-Wrap service calls in try-catch, return safe defaults:
-
-```typescript
-const data = await service.fetch(...).catch(() => []);
-```
-
-Prevents single service error from crashing entire page.
+All async operations must be inside the try-catch block, not before it. Errors from async calls outside propagate unhandled.
## Auth Audit
-When adding guard to one action in `+page.server.ts`, audit all other actions. Asymmetric guards (some protected, others not) are a recurring vulnerability.
+When protecting one action (in `load()` or form actions), audit all others. Asymmetric guards are a critical vulnerability.
## success Flag & message Consistency
diff --git a/.claude/rules/testing-e2e.md b/.claude/rules/testing-e2e.md
index 7c8063c4e..4e1303c51 100644
--- a/.claude/rules/testing-e2e.md
+++ b/.claude/rules/testing-e2e.md
@@ -99,6 +99,28 @@ Prefer in order:
- `page.pause()` for interactive debugging
- Logs: `page.on('console', msg => console.log(msg.text()))`
+## Multi-Route Infrastructure Changes: Test Ordering
+
+When implementing infrastructure changes that span multiple routes (e.g., redirect behavior, auth flow), write E2E tests **after** the core mechanism is implemented but **before** applying it to all routes:
+
+1. **Implement core mechanism** (unit-tested) — e.g., `isSameOriginRedirect()`, `redirect()` logic
+2. **Write E2E tests** (RED state) — tests fail until all routes are updated
+3. **Apply to all routes** (GREEN state) — update each load/action function to use the mechanism
+
+**Why this order?**
+
+- If you write E2E tests before the mechanism exists, they'll fail immediately and block progress
+- E2E tests verify integration end-to-end; unit tests verify the building blocks
+- The RED→GREEN cycle for E2E tests spans multiple phases, unlike unit tests which turn GREEN immediately
+
+**Example:** Redirect After Login pattern
+
+- Phase 1-2: Unit test `isSameOriginRedirect()` (GREEN)
+- Phase 3-4: Unit test auth helpers (GREEN)
+- Phase 5-6: Implement login/signup actions (GREEN)
+- Phase 7: Write E2E tests for all routes (RED)
+- Phase 8-9: Update all load functions to pass `url` (GREEN)
+
## E2E Lessons from Votes Tests
Recent fixes (detailed in plan.md):
@@ -106,3 +128,14 @@ Recent fixes (detailed in plan.md):
- Dropdown selectors drift when UI changes; re-verify on refactors
- Abort signal prevents stale response race; update signal if fetch signature changes
- Toast messages are temporary; use waiter pattern, not fixed delay
+
+## Timeout: Element Not Found
+
+When `page.locator()` or `getByRole()` times out:
+
+1. Verify element exists in DevTools
+2. Check ARIA roles in component UI libraries often lack semantic role definitions
+3. If `role="menuitem"` fails: use href pattern or functional marker instead (e.g., `a[href^="/login?redirectTo"]` vs navbar's `/login`)
+4. Never use `.first()` / `.last()` (DOM order is unstable)
+
+**Example:** `getByRole('menuitem')` times out because DropdownItem renders as `
` without the role. Use `a[href^="/login?redirectTo"]` to distinguish dropdown from navbar.
diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md
index 22fce4087..8533dcfc1 100644
--- a/.claude/rules/testing.md
+++ b/.claude/rules/testing.md
@@ -32,6 +32,18 @@ E2E files: **must** use `.spec.ts` extension (`.test.ts` not detected).
- Use `toBe(true)` / `toBe(false)` not `toBeTruthy()` / `toBeFalsy()`
- DB query tests: assert `orderBy`, `include` with `expect.objectContaining`, not just `where`
- Enum membership: `Object.hasOwn(Enum, value)` not `in` (avoids prototype chain)
+- `Promise`: `.resolves` requires a matcher — without one it does not assert and becomes a false-positive; use `await fn()` to assert no throw, or `.resolves.toBeUndefined()` for explicit form
+
+```typescript
+// ✓ Preferred: simplest way to assert Promise does not throw
+await ensureSessionOrRedirect(mockLocals);
+
+// ✓ Also correct: explicit resolves form
+await expect(ensureSessionOrRedirect(mockLocals)).resolves.toBeUndefined();
+
+// ✗ Wrong: .resolves without a matcher does not assert anything (false-positive)
+await expect(ensureSessionOrRedirect(mockLocals)).resolves;
+```
### Describe Organization
diff --git a/AGENTS.md b/AGENTS.md
index 7b394d932..c944564e2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,6 +19,34 @@ Always prefer simplicity over pathological correctness. YAGNI, KISS, DRY. No bac
5. After all phases complete (feature and refactor branches only — not hotfixes or dependency bumps): run a mandatory refactor cycle. Write to `plan.md`: novel lessons (implementation blockers, non-obvious patterns not already in rules) and remaining tasks. Discard `phase-N.md` files. Run `coderabbit review --plain`; write all findings of `critical` / `high` / `potential_issue` (medium) to a `## CodeRabbit Findings` section in `plan.md`. The user decides which to fix before opening a PR; do not fix any finding unilaterally. `nitpick` findings defer to PR CI.
6. Run `/session-close` at the end of each session: updates plan checklist, proposes rule/skill additions, checks for bloat, and detects repeated instructions
+**Plan Approval ≠ Implementation Start:** Generating a plan (`/writing-plans`) does NOT authorize implementation. Always:
+
+- Wait for explicit user consent ("let's start", "implement", etc.)
+- Use AskUserQuestion before starting if requirements are ambiguous (data model, preferences, test scope, etc.)
+
+## Working with the User
+
+### Communication
+
+- **No social pleasantries:** Skip opening remarks like "お疲れ様です", "了解しました", "ありがとうございます"
+- **Direct & concise:** Lead with substance (findings, next steps, decisions)
+- **Task-focused:** Avoid flattery, apologizing, or performative agreement
+- **Respect the work:** Speak plainly about tradeoffs and real issues; don't sugar-coat
+
+### Responding to Feedback
+
+- **Verify before accepting:** Check for misunderstanding, missing context, or counterpoints
+- **Question, don't defer:** Ask clarifying questions or offer alternatives with reasoning—don't blindly follow
+
+### Critical Review as Default
+
+When proposing approaches, designs, or solutions:
+
+- **Lead with the proposal:** State the recommendation clearly
+- **Then critique it:** Point out tradeoffs, risks, limitations, and when it fails
+- **Offer alternatives:** Present 2–3 viable options with reasoning for each
+- **Let user decide:** Don't pretend one option is obviously best if it isn't
+
## Tech Stack
SvelteKit 2 + Svelte 5 (Runes) + TypeScript | PostgreSQL + Prisma | Flowbite Svelte + Tailwind 4 | Vitest + Playwright | oxlint (JS/TS) + ESLint (Svelte)
diff --git a/docs/dev-notes/2026-05-06/auth-consolidation-review/review.md b/docs/dev-notes/2026-05-06/auth-consolidation-review/review.md
new file mode 100644
index 000000000..a6787cafc
--- /dev/null
+++ b/docs/dev-notes/2026-05-06/auth-consolidation-review/review.md
@@ -0,0 +1,274 @@
+# Auth Feature Consolidation (#1582) - 総合レビューレポート
+
+**実施日:** 2026-05-06
+**ブランチ:** #1582
+**実施内容:** Security Review + CodeRabbit ×2
+
+---
+
+## 📋 Executive Summary
+
+### セキュリティレビュー結論
+
+**⚠️ 残存課題(Phase 2 以降):MEDIUM-HIGH リスク**
+
+- Service 層が redirect()/error() を呼び出し(設計違反)
+- Null チェック不足による NPE リスク(HIGH)
+
+---
+
+## 🔒 Security Findings
+
+### ⚠️ HIGH: Non-null Assertion での NPE
+
+**Files:**
+
+- `src/routes/problems/[slug]/+page.server.ts:12-13, 23-24`
+- `src/routes/workbooks/edit/[slug]/+page.server.ts:25`
+
+**Issue:**
+
+```typescript
+// ❌ loggedInUser が null でも !.id でアクセス
+const userId = loggedInUser!.id;
+```
+
+**Recommendation:**
+
+```typescript
+// ✅ Null チェック後に安全にアクセス
+if (!loggedInUser) {
+ return fail(BAD_REQUEST, { error: 'Unauthorized' });
+}
+const userId = loggedInUser.id;
+```
+
+---
+
+### ⚠️ MEDIUM: Service 層の設計違反
+
+**Files:**
+
+- `src/features/auth/services/session.ts:13-25` - `redirect()` を呼び出し
+- `src/features/auth/services/admin_access.ts:28-32` - `redirect()` を呼び出し
+- `src/features/auth/services/admin_access.ts:38-48` - `error()` を呼び出し
+
+**Issue:** AGENTS.md ガイドラインに違反
+
+> Service functions return data or null; never call error() or redirect(). HTTP error translation belongs in route handlers.
+
+**Current (❌):**
+
+```typescript
+export async function validateAdminAccess(locals: App.Locals, url?: URL): Promise {
+ if ((await validateAdminStatus(locals)) !== AdminStatus.OK) {
+ redirect(TEMPORARY_REDIRECT, buildLoginPath(url)); // ❌ Service で redirect
+ }
+}
+```
+
+**Recommended (✅): Wrapper パターン**
+
+Service 層は純粋、Route helper で framework 処理を集約:
+
+```typescript
+// src/features/auth/services/admin_access.ts (純粋、テスト可能)
+export async function validateAdminStatus(locals: App.Locals): Promise {
+ // ...
+}
+
+// src/features/auth/utils/admin_access.ts (Route helper)
+export async function ensureAdminOrRedirect(locals: App.Locals, url?: URL): Promise {
+ const status = await validateAdminStatus(locals);
+ if (status !== AdminStatus.OK) {
+ redirect(TEMPORARY_REDIRECT, buildLoginPath(url));
+ }
+}
+```
+
+**呼び出し側:**
+
+```typescript
+// Route handler
+await ensureAdminOrRedirect(locals, url);
+
+// Form action
+await ensureAdminOrRedirect(locals);
+```
+
+利点:Service は純粋 + 呼び出し側は冗長性ゼロ
+
+ただし `utils/` は「副作用禁止」の規約があるため、`ensureAdminOrRedirect` の配置先は規約上グレー。
+
+**代替案:SvelteKit レイアウト階層による一元化**
+
+admin ページが `/admin/` 配下に集約されているなら、layout で一度だけ書くことで書き漏れを構造的に防げる:
+
+```typescript
+// src/routes/(admin)/+layout.server.ts
+export async function load({ locals, url }) {
+ const status = await validateAdminStatus(locals);
+ if (status !== AdminStatus.OK) redirect(TEMPORARY_REDIRECT, buildLoginPath(url));
+}
+```
+
+- `redirect()` が規約上の正規の場所(route handler)に収まる
+- 各ページへの書き漏れリスクがゼロ
+- wrapper 関数が不要になるため utils の副作用問題も消える
+
+制約:admin ルートが複数の route group に散らばっている場合は適用できない。
+
+---
+
+### ⚠️ MEDIUM: Module-scope Mutable State
+
+**File:** `src/routes/(admin)/account_transfer/+page.server.ts:14`
+
+```typescript
+let accountTransferMessages: FloatingMessage[] = []; // ❌ 全リクエスト間で共有
+```
+
+**Risks:**
+
+- 競合状態(複数ユーザーが同時実行時に値が上書き)
+- 情報漏洩(ユーザーAの結果がユーザーBに表示)
+
+**Recommendation:** Superforms の message 機能または cookies を使用してリクエスト単位に管理
+
+---
+
+## 🐛 CodeRabbit Findings(×2回実施)
+
+### HIGH Issues(残存)
+
+| Issue | File | Line | Count |
+| --------------------- | ---------------------------------------------------- | ------------ | ------ |
+| Null チェック漏れ | `src/routes/problems/[slug]/+page.server.ts` | 12-13, 23-24 | 2 |
+| Service で redirect() | `src/features/auth/services/session.ts` | 13-25 | 2 箇所 |
+| Service で error() | `src/features/auth/services/admin_access.ts` | 38-48 | 1 |
+| Task 型不整合 | `src/routes/(admin)/tasks/[task_id]/+page.server.ts` | 14, 44-45 | 2 |
+
+---
+
+### MEDIUM Issues
+
+| Issue | File | Line | Detail |
+| ------------------------ | ----------------------------------------------------- | ------------- | -------------------------------------------- |
+| テストアサーション不完全 | `src/features/auth/services/session.test.ts` | 52 | `.resolves` にマッチャーなし |
+| テストアサーション不完全 | `src/features/auth/services/admin_access.test.ts` | 54-55, 99-100 | `.resolves` にマッチャーなし |
+| Module-scope mutable | `src/routes/(admin)/account_transfer/+page.server.ts` | 14 | 情報漏洩リスク |
+| 型不整合 | `src/routes/(admin)/tags/[tag_id]/+page.server.ts` | 13 | `getTag` が Tags 返却 → Tag 単一返却が正しい |
+| E2E パスワード不一致 | `e2e/helpers/auth.ts` | 6-11 | signup 'TestPass123' vs login 'Ch0kuda1' |
+
+---
+
+### NITPICK Issues(オプション)
+
+| Issue | File | Count |
+| --------------------- | ---------------------------------------------------------------- | ---------------- |
+| テストファイル不足 | `src/features/auth/utils/login.ts`, `signup.ts` | 2 |
+| Helper 関数重複 | `src/lib/utils/url.test.ts` | 3 箇所 |
+| 冗長な型キャスト | `src/features/auth/services/session.test.ts` | 2 |
+| 型キャスト `as never` | `src/features/auth/services/admin_access.test.ts` | 4 |
+| 型安全性低下 | `src/lib/components/AuthForm.svelte:31` | string に拡張 |
+| Reactive 漏れ | `src/lib/components/SubmissionStatus/UpdatingDropdown.svelte:47` | const → $derived |
+
+---
+
+## 📊 優先度別修正スケジュール
+
+### 完了済み(本レビュー対応)
+
+```markdown
+- [x] Account Transfer actions.default に validateAdminAccess(locals, url) 追加
+ → src/routes/(admin)/account_transfer/+page.server.ts
+```
+
+### Phase 2: Service 層リファクタ(短期)
+
+```markdown
+- [ ] NPE リスク → null チェックに変更(3 箇所
+- [ ] ensureSessionOrRedirect → 純粋関数 + Wrapper パターン
+- [ ] getLoggedInUser の戻り値型を正確に
+- [ ] validateAdminStatus → 純粋関数、ensureAdminOrRedirect Wrapper を提供
+- [ ] validateAdminAccessForApi → 純粋関数化、error() は Route に移動
+```
+
+**見積:** 3-4 時間 + テスト修正 | **パターン:** Wrapper 関数で Service 純粋性 + Route 簡潔性を両立
+
+### Phase 3: テスト・ドキュメント(並行可)
+
+```markdown
+- [ ] テストアサーション補完(.resolves → .resolves.toBeUndefined())
+- [ ] login.test.ts, signup.test.ts を追加
+- [ ] E2E パスワード統一
+- [ ] Module-scope state をリクエスト単位に変更
+```
+
+**見積:** 2-3 時間
+
+### Phase 4: 最適化(オプション)
+
+```markdown
+- [ ] runTests helper 統一
+- [ ] 型安全性改善
+- [ ] Reactive store 修正
+```
+
+**見積:** 1-2 時間
+
+---
+
+## 🔍 Critical Code Review - レビュー指摘事項の批判的検討
+
+### 対処済み(詳細はコミット履歴参照)
+
+| Issue | 判定 | 対処内容 |
+| ------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
+| workbooks/[slug] 可視性チェック | 却下 | `getLoggedInUser` が guarantee するため現状正しい。ゲスト閲覧要件が来た場合は `canRead` 型含め変更が必要 |
+| workbooks delete 匿名許可 | 却下 | 同上。ただし Phase 2 pure 化時に `if (loggedInUser &&` が穴になる(非可逆削除)— Phase 2 要注意 |
+| Account Transfer 認可チェックなし | 修正済 | `actions.default` に `validateAdminAccess(locals, url)` を追加(`validateAdminAccessForApi` は `+server.ts` 専用のため不可) |
+| sveltekit.md の `url` 記述誤り | 修正済 | `url` は form actions でも使用可能・non-null。公式ドキュメントで確認済み |
+| buildSignupPath ドキュメント漏れ・型非対称 | 修正済 | `auth.md` に追記、型を `string \| URL \| null` に統一 |
+| testing.md の `.resolves` 説明誤り | 修正済 | `.resolves` のみは false-positive。`await fn()` パターンに統一 |
+| users/edit エラーマスキング | 修正済 | catch 内の `redirect(login)` を `error(500)` に変更 |
+
+### 未解決・議論が必要な案件
+
+#### ❓ Issue 10: Guest Login の Redirect 対応は必要か?
+
+**File:** `src/lib/components/AuthForm.svelte:89-92`
+
+**ユーザー質問:** 「ゲストログインもリダイレクトに対応すべき?自分のアカウント作らないのに、そこまでする必要はなくない?」
+
+**批判的分析:**
+
+- **Pro:** UI/UX の一貫性(ゲスト login も admin login も同じ flow)
+- **Con:** ゲスト = セッションレス、`redirectTo` 保持の意味が薄い。セッション破棄時に `redirectTo` も消える可能性
+- **設計質問:** ゲストが「ログイン前のページに戻る」動作は要件か?
+
+**提案:** 要件確認 → 不要なら NITPICK、必要なら MEDIUM に格上げ
+
+---
+
+#### ❓ Issue 11: workbooks/create の null-check は本当に不要か?
+
+**File:** `src/routes/workbooks/create/+page.server.ts:36-47`
+
+**指摘:** `getLoggedInUser()` が常に throw するので、その下の `if (!author)` は unreachable。
+
+**批判的評価:**
+
+- 指摘は **技術的に正確**
+- ただし「この check は将来の refactor 時の safeguard」という観点もある
+- Service 層が pure 化される Phase 2 では、`getLoggedInUser()` は `User | null` を返す可能性 → 再度必要になる
+- 今削除 → Phase 2 で復活するデッド・コードの往復
+
+**提案:** Phase 2 roadmap が確定まで保留、またはコメント付きで明示的に「Phase 2 refactor 後に復活する可能性」と注記
+
+---
+
+## 参考
+
+- [AGENTS.md - Service Layer Guidelines](../../AGENTS.md)
+- [.claude/rules/auth.md - Authentication Rules](../../.claude/rules/auth.md)
diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts
index 60eacfb1d..8b3d0762b 100644
--- a/e2e/helpers/auth.ts
+++ b/e2e/helpers/auth.ts
@@ -3,6 +3,13 @@ import { expect, type Page } from '@playwright/test';
const TIMEOUT = 60 * 1000;
const SHARED_PASSWORD = 'Ch0kuda1';
+/** Fills and submits the signup form with a unique username. */
+export async function submitSignupForm(page: Page): Promise {
+ await page.locator('input[name="username"]').fill(`testuser_${Date.now()}`);
+ await page.locator('input[name="password"]').fill('TestPass123');
+ await page.getByRole('button', { name: 'アカウントを作成', exact: true }).click();
+}
+
/** Logs in as the admin user and waits for redirect to home. */
export async function loginAsAdmin(page: Page): Promise {
await loginAs(page, 'admin');
@@ -13,11 +20,23 @@ export async function loginAsUser(page: Page): Promise {
await loginAs(page, 'guest');
}
+/**
+ * Fills and submits the login form without navigating first.
+ * Use when already on /login?redirectTo=... to preserve the redirect destination.
+ */
+export async function submitLoginForm(page: Page, username: 'admin' | 'guest'): Promise {
+ await fillLoginForm(page, username);
+}
+
async function loginAs(page: Page, username: string): Promise {
await page.goto('/login');
await expect(page).toHaveURL('/login', { timeout: TIMEOUT });
+ await fillLoginForm(page, username);
+ await expect(page).toHaveURL('/', { timeout: TIMEOUT });
+}
+
+async function fillLoginForm(page: Page, username: string): Promise {
await page.locator('input[name="username"]').fill(username);
await page.locator('input[name="password"]').fill(SHARED_PASSWORD);
await page.getByRole('button', { name: 'ログイン', exact: true }).click();
- await expect(page).toHaveURL('/', { timeout: TIMEOUT });
}
diff --git a/e2e/redirect_after_login.spec.ts b/e2e/redirect_after_login.spec.ts
new file mode 100644
index 000000000..3ab908c75
--- /dev/null
+++ b/e2e/redirect_after_login.spec.ts
@@ -0,0 +1,203 @@
+import { test, expect, type Page } from '@playwright/test';
+import { submitLoginForm, submitSignupForm } from './helpers/auth';
+
+/**
+ * Opens the submission status dropdown for a given task on the /problems page.
+ *
+ * Steps:
+ * 1. Click the contest table tab (in case it is not the default)
+ * 2. Click the contest group button identified by contestGroupAriaLabel
+ * 3. Click the dropdown trigger inside the cell identified by contestId and taskIndex
+ *
+ * The dropdown items are not in the DOM until the trigger is clicked (Flowbite renders them lazily).
+ *
+ * @param contestGroupAriaLabel - aria-label of the contest group button (e.g. 'Filter contests from ABC 319 onwards')
+ * @param contestId - contest ID portion of the cell ID (e.g. 'abc422')
+ * @param taskIndex - task column index (e.g. 'A')
+ */
+async function openProblemSubmissionDropdown(
+ page: Page,
+ contestGroupAriaLabel: string,
+ contestId: string,
+ taskIndex: string,
+): Promise {
+ await page.getByRole('tab', { name: 'コンテスト別(アルファ版)' }).click();
+ await page.getByLabel(contestGroupAriaLabel).click();
+ await page.locator(`#${contestId}-${taskIndex}`).getByLabel('Update submission status').click();
+}
+
+test.describe('redirect with redirectTo parameter', () => {
+ test.describe('login', () => {
+ test.describe('redirectTo preserved in login links', () => {
+ test.describe('server-side (protected routes)', () => {
+ test.describe('workbooks', () => {
+ const workbookRoutes = [
+ '/workbooks',
+ '/workbooks/bfs',
+ '/workbooks/create',
+ '/workbooks/edit/bfs',
+ ];
+
+ for (const route of workbookRoutes) {
+ test(`redirects to login for ${route}`, async ({ page }) => {
+ await page.goto(route);
+ await expect(page).toHaveURL(
+ new RegExp(`/login\\?redirectTo=${encodeURIComponent(route)}`),
+ );
+ });
+ }
+ });
+
+ test.describe('users', () => {
+ test('edit page redirects without login', async ({ page }) => {
+ await page.goto('/users/edit');
+ await expect(page).toHaveURL(/\/login\?redirectTo=%2Fusers%2Fedit/);
+ });
+ });
+
+ test.describe('admin', () => {
+ const adminRoutes = [
+ '/account_transfer',
+ '/tasks',
+ '/tasks/1',
+ '/tags',
+ '/tags/1',
+ '/vote_management',
+ '/workbooks/order',
+ ];
+
+ for (const route of adminRoutes) {
+ test(`redirects to login for ${route}`, async ({ page }) => {
+ await page.goto(route);
+ await expect(page).toHaveURL(
+ new RegExp(`/login\\?redirectTo=${encodeURIComponent(route)}`),
+ );
+ });
+ }
+ });
+ });
+
+ test.describe('client-side (action pages)', () => {
+ test.describe('problems', () => {
+ test('login dropdown item includes redirectTo parameter', async ({ page }) => {
+ await page.goto('/problems');
+ await openProblemSubmissionDropdown(
+ page,
+ 'Filter contests from ABC 319 onwards',
+ 'abc422',
+ 'A',
+ );
+ // Href pattern distinguishes dropdown (/login?redirectTo=*) from navbar (/login)
+ const href = await page.locator('a[href^="/login?redirectTo"]').getAttribute('href');
+ expect(href).toMatch(/^\/login\?redirectTo=%2Fproblems/);
+ });
+ });
+
+ test.describe('votes', () => {
+ test('login link includes redirectTo parameter', async ({ page }) => {
+ await page.goto('/votes/abc422_a');
+ const href = await page.locator('a[href^="/login?redirectTo"]').getAttribute('href');
+ expect(href).toMatch(/^\/login\?redirectTo=%2Fvotes%2Fabc422_a/);
+ });
+ });
+ });
+ });
+
+ test.describe('redirects back to original page after login', () => {
+ // Note: Representative paths only: one user route and one admin route.
+ // The redirect mechanism is shared, so exhaustive per-route coverage adds no value over
+ // the redirect-to-login tests above.
+ test('user can login and be redirected to /workbooks/bfs', async ({ page }) => {
+ await page.goto('/workbooks/bfs');
+ await expect(page).toHaveURL(/\/login\?redirectTo=%2Fworkbooks%2Fbfs/);
+ await submitLoginForm(page, 'guest');
+ await expect(page).toHaveURL('/workbooks/bfs');
+ });
+
+ test('admin can login and be redirected to admin page', async ({ page }) => {
+ await page.goto('/tags');
+ await expect(page).toHaveURL(/\/login\?redirectTo=%2Ftags/);
+ await submitLoginForm(page, 'admin');
+ await expect(page).toHaveURL('/tags');
+ });
+ });
+ });
+
+ test.describe('signup', () => {
+ test.describe('redirectTo preserved in signup links', () => {
+ test.describe('client-side (action pages)', () => {
+ test.describe('problems', () => {
+ test('signup dropdown item includes redirectTo parameter', async ({ page }) => {
+ await page.goto('/problems');
+ await openProblemSubmissionDropdown(
+ page,
+ 'Filter contests from ABC 319 onwards',
+ 'abc422',
+ 'A',
+ );
+ // Href pattern distinguishes dropdown (/signup?redirectTo=*) from navbar (/signup)
+ const href = await page.locator('a[href^="/signup?redirectTo"]').getAttribute('href');
+ expect(href).toMatch(/^\/signup\?redirectTo=%2Fproblems/);
+ });
+ });
+
+ test.describe('votes', () => {
+ test('signup link includes redirectTo parameter', async ({ page }) => {
+ await page.goto('/votes/abc422_a');
+ const href = await page.locator('a[href^="/signup?redirectTo"]').getAttribute('href');
+ expect(href).toMatch(/^\/signup\?redirectTo=%2Fvotes%2Fabc422_a/);
+ });
+ });
+ });
+ });
+
+ test.describe('redirects back to original page after signup', () => {
+ test('user can signup via login page link and be redirected to /workbooks/bfs', async ({
+ page,
+ }) => {
+ await page.goto('/workbooks/bfs');
+ await expect(page).toHaveURL(/\/login\?redirectTo=%2Fworkbooks%2Fbfs/);
+
+ await page.getByRole('link', { name: 'アカウントを作成' }).click();
+ await expect(page).toHaveURL(/\/signup/);
+
+ await submitSignupForm(page);
+ await expect(page).toHaveURL('/workbooks/bfs', { timeout: 60 * 1000 });
+ });
+
+ test('preserves redirectTo from votes page through signup link', async ({ page }) => {
+ await page.goto('/votes/abc422_a');
+ const href = await page.locator('a[href^="/signup?redirectTo"]').getAttribute('href');
+ await page.goto(href!);
+ await expect(page).toHaveURL(
+ new RegExp(`/signup\\?redirectTo=${encodeURIComponent('/votes/abc422_a')}`),
+ );
+
+ await submitSignupForm(page);
+ await expect(page).toHaveURL('/votes/abc422_a', { timeout: 60 * 1000 });
+ });
+
+ test('defaults to home page when no redirectTo', async ({ page }) => {
+ await page.goto('/signup');
+ await submitSignupForm(page);
+ await expect(page).toHaveURL('/', { timeout: 60 * 1000 });
+ });
+ });
+ });
+
+ test.describe('open redirect prevention', () => {
+ const maliciousRedirects = [
+ { label: 'external domain', redirectTo: 'https://danger.com' },
+ { label: 'protocol-relative URL', redirectTo: '%2F%2Fdanger.com' },
+ { label: 'javascript: URL', redirectTo: 'javascript%3Aalert()' },
+ ];
+
+ for (const { label, redirectTo } of maliciousRedirects) {
+ test(`prevents redirect to ${label}`, async ({ page }) => {
+ await page.goto(`/login?redirectTo=${redirectTo}`);
+ await submitLoginForm(page, 'guest');
+ await expect(page).toHaveURL('/');
+ });
+ }
+ });
+});
diff --git a/e2e/votes.spec.ts b/e2e/votes.spec.ts
index 6d104ec38..8cf06b4c5 100644
--- a/e2e/votes.spec.ts
+++ b/e2e/votes.spec.ts
@@ -166,7 +166,7 @@ test.describe('vote detail page (/votes/[slug])', () => {
test.describe('vote management page (/vote_management)', () => {
test('unauthenticated user is redirected to /login', async ({ page }) => {
await page.goto(VOTE_MANAGEMENT_URL);
- await expect(page).toHaveURL('/login', { timeout: TIMEOUT });
+ await expect(page).toHaveURL(/\/login/, { timeout: TIMEOUT });
});
test('non-admin user is redirected to /', async ({ page }) => {
diff --git a/e2e/workbook_order.spec.ts b/e2e/workbook_order.spec.ts
index 2e3485e8c..34751385a 100644
--- a/e2e/workbook_order.spec.ts
+++ b/e2e/workbook_order.spec.ts
@@ -13,7 +13,7 @@ const ORDER_URL = '/workbooks/order';
test.describe('access control', () => {
test('unauthenticated user is redirected to /login', async ({ page }) => {
await page.goto(ORDER_URL);
- await expect(page).toHaveURL('/login', { timeout: TIMEOUT });
+ await expect(page).toHaveURL(/\/login/, { timeout: TIMEOUT });
});
});
diff --git a/e2e/workbooks_list.spec.ts b/e2e/workbooks_list.spec.ts
index 45de0310b..6a773e7eb 100644
--- a/e2e/workbooks_list.spec.ts
+++ b/e2e/workbooks_list.spec.ts
@@ -29,7 +29,7 @@ const CATEGORY_SEARCH = 'SEARCH_SIMULATION';
test.describe('access control', () => {
test('unauthenticated user is redirected to /login', async ({ page }) => {
await page.goto(WORKBOOK_LIST_URL);
- await expect(page).toHaveURL('/login', { timeout: TIMEOUT });
+ await expect(page).toHaveURL(/\/login/, { timeout: TIMEOUT });
});
});
diff --git a/src/features/auth/services/admin_access.test.ts b/src/features/auth/services/admin_access.test.ts
new file mode 100644
index 000000000..056bf528b
--- /dev/null
+++ b/src/features/auth/services/admin_access.test.ts
@@ -0,0 +1,123 @@
+import { expect, test, describe, vi, afterEach } from 'vitest';
+
+vi.mock('@sveltejs/kit', () => {
+ const redirectImpl = (status: number, location: string) => {
+ const err = new Error('Redirect') as Error & { status: number; location: string };
+ err.name = 'Redirect';
+ err.status = status;
+ err.location = location;
+ throw err;
+ };
+
+ const errorImpl = (status: number) => {
+ const err = new Error('HttpError') as Error & { status: number };
+ err.name = 'HttpError';
+ err.status = status;
+ throw err;
+ };
+
+ return { redirect: vi.fn(redirectImpl), error: vi.fn(errorImpl) };
+});
+
+vi.mock('$lib/services/users', () => ({
+ getUser: vi.fn(),
+}));
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+import * as userService from '$lib/services/users';
+import { Roles } from '$lib/types/user';
+import { validateAdminAccess, validateAdminAccessForApi } from './admin_access';
+
+const createMockLocalsWithSession = (username = 'admin-user') =>
+ ({
+ auth: {
+ validate: vi.fn().mockResolvedValue({ user: { username } }),
+ },
+ }) as unknown as App.Locals;
+
+const createMockLocalsWithoutSession = () =>
+ ({
+ auth: {
+ validate: vi.fn().mockResolvedValue(null),
+ },
+ }) as unknown as App.Locals;
+
+describe('validateAdminAccess', () => {
+ describe('successful cases', () => {
+ test('does not throw when user is admin', async () => {
+ const mockLocals = createMockLocalsWithSession();
+ vi.mocked(userService.getUser).mockResolvedValue({ role: Roles.ADMIN } as never);
+
+ await validateAdminAccess(mockLocals);
+ });
+ });
+
+ describe('error cases', () => {
+ test('redirects to /login when no session', async () => {
+ const mockLocals = createMockLocalsWithoutSession();
+
+ await expect(validateAdminAccess(mockLocals)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login',
+ });
+ });
+
+ test('redirects to /login when user is not admin', async () => {
+ const mockLocals = createMockLocalsWithSession();
+ vi.mocked(userService.getUser).mockResolvedValue({ role: Roles.USER } as never);
+
+ await expect(validateAdminAccess(mockLocals)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login',
+ });
+ });
+
+ test('redirects to /login?redirectTo=... when no session and url provided', async () => {
+ const mockLocals = createMockLocalsWithoutSession();
+ const mockUrl = new URL('http://localhost:5174/admin/tasks');
+
+ await expect(validateAdminAccess(mockLocals, mockUrl)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login?redirectTo=%2Fadmin%2Ftasks',
+ });
+ });
+ });
+});
+
+describe('validateAdminAccessForApi', () => {
+ describe('successful cases', () => {
+ test('does not throw when user is admin', async () => {
+ const mockLocals = createMockLocalsWithSession();
+ vi.mocked(userService.getUser).mockResolvedValue({ role: Roles.ADMIN } as never);
+
+ await validateAdminAccessForApi(mockLocals);
+ });
+ });
+
+ describe('error cases', () => {
+ test('throws 401 when no session', async () => {
+ const mockLocals = createMockLocalsWithoutSession();
+
+ await expect(validateAdminAccessForApi(mockLocals)).rejects.toMatchObject({
+ name: 'HttpError',
+ status: 401,
+ });
+ });
+
+ test('throws 403 when user is not admin', async () => {
+ const mockLocals = createMockLocalsWithSession();
+ vi.mocked(userService.getUser).mockResolvedValue({ role: Roles.USER } as never);
+
+ await expect(validateAdminAccessForApi(mockLocals)).rejects.toMatchObject({
+ name: 'HttpError',
+ status: 403,
+ });
+ });
+ });
+});
diff --git a/src/routes/(admin)/_utils/auth.ts b/src/features/auth/services/admin_access.ts
similarity index 79%
rename from src/routes/(admin)/_utils/auth.ts
rename to src/features/auth/services/admin_access.ts
index 916262bda..7850b6d22 100644
--- a/src/routes/(admin)/_utils/auth.ts
+++ b/src/features/auth/services/admin_access.ts
@@ -5,14 +5,14 @@ import * as userService from '$lib/services/users';
import { Roles } from '$lib/types/user';
import { isAdmin } from '$lib/utils/authorship';
-
-import { LOGIN_PAGE } from '$lib/constants/navbar-links';
import {
TEMPORARY_REDIRECT,
UNAUTHORIZED,
FORBIDDEN,
} from '$lib/constants/http-response-status-codes';
+import { buildLoginPath } from '../utils/login';
+
enum AdminStatus {
OK = 'ok',
UNAUTHENTICATED = 'unauthenticated',
@@ -22,10 +22,12 @@ enum AdminStatus {
/**
* Validates that the current session belongs to an admin user.
* Redirects to the login page if the session is missing or the user is not an admin.
+ * @param locals - The application locals containing auth and user information
+ * @param url - The current URL; when provided, appends ?redirectTo= so the user returns after login
*/
-export async function validateAdminAccess(locals: App.Locals): Promise {
+export async function validateAdminAccess(locals: App.Locals, url?: URL): Promise {
if ((await validateAdminStatus(locals)) !== AdminStatus.OK) {
- redirect(TEMPORARY_REDIRECT, LOGIN_PAGE);
+ redirect(TEMPORARY_REDIRECT, buildLoginPath(url));
}
}
diff --git a/src/features/auth/services/session.test.ts b/src/features/auth/services/session.test.ts
new file mode 100644
index 000000000..7b754eeab
--- /dev/null
+++ b/src/features/auth/services/session.test.ts
@@ -0,0 +1,136 @@
+import { expect, test, describe, vi, afterEach } from 'vitest';
+
+vi.mock('@sveltejs/kit', () => {
+ const redirectImpl = (status: number, location: string) => {
+ const err = new Error('Redirect') as Error & { status: number; location: string };
+ err.name = 'Redirect';
+ err.status = status;
+ err.location = location;
+ throw err;
+ };
+
+ return { redirect: vi.fn(redirectImpl) };
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+import { ensureSessionOrRedirect, getLoggedInUser } from './session';
+
+const createMockLocalsWithValidSession = (user = { id: 'test-user', name: 'Test User' }) =>
+ ({
+ auth: {
+ validate: vi.fn().mockResolvedValue({ user: { id: user.id } }),
+ },
+ user,
+ }) as unknown as App.Locals;
+
+const createMockLocalsWithoutSession = () =>
+ ({
+ auth: {
+ validate: vi.fn().mockResolvedValue(null),
+ },
+ }) as unknown as App.Locals;
+
+const createMockLocalsWithSessionButNoUser = () =>
+ ({
+ auth: {
+ validate: vi.fn().mockResolvedValue({ user: { id: 'test-user' } }),
+ },
+ user: null,
+ }) as unknown as App.Locals;
+
+const createMockUrlWorkbooksSlug = (slug = 'bfs') =>
+ new URL(`http://localhost:5174/workbooks/${slug}`);
+
+describe('ensureSessionOrRedirect', () => {
+ describe('successful cases', () => {
+ test('does not throw when user has valid session', async () => {
+ const mockLocals = createMockLocalsWithValidSession();
+
+ await ensureSessionOrRedirect(mockLocals);
+ expect(mockLocals.auth.validate).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('error cases', () => {
+ test('redirects to /login when no session', async () => {
+ const mockLocals = createMockLocalsWithoutSession();
+
+ await expect(ensureSessionOrRedirect(mockLocals)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login',
+ });
+ });
+
+ test('redirects to /login?redirectTo=... when no session exists and url is provided', async () => {
+ const mockLocals = createMockLocalsWithoutSession();
+ const mockUrl = createMockUrlWorkbooksSlug();
+
+ await expect(ensureSessionOrRedirect(mockLocals, mockUrl)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login?redirectTo=%2Fworkbooks%2Fbfs',
+ });
+ });
+ });
+});
+
+describe('getLoggedInUser', () => {
+ describe('successful cases', () => {
+ test('returns user when session and user exist', async () => {
+ const mockUser = { id: 'test-user', name: 'Test User' };
+ const mockLocals = createMockLocalsWithValidSession(mockUser) as unknown as App.Locals;
+
+ const result = await getLoggedInUser(mockLocals);
+
+ expect(result).toEqual(mockUser);
+ expect(mockLocals.auth.validate).toHaveBeenCalledTimes(1);
+ });
+
+ test('returns user when session and user exist (with url param)', async () => {
+ const mockUser = { id: 'test-user', name: 'Test User' };
+ const mockLocals = createMockLocalsWithValidSession(mockUser) as unknown as App.Locals;
+ const mockUrl = createMockUrlWorkbooksSlug();
+
+ const result = await getLoggedInUser(mockLocals, mockUrl);
+
+ expect(result).toEqual(mockUser);
+ });
+ });
+
+ describe('error cases', () => {
+ test('redirects to /login when no session', async () => {
+ const mockLocals = createMockLocalsWithoutSession();
+
+ await expect(getLoggedInUser(mockLocals)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login',
+ });
+ });
+
+ test('redirects to /login when session exists but no user', async () => {
+ const mockLocals = createMockLocalsWithSessionButNoUser();
+
+ await expect(getLoggedInUser(mockLocals)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login',
+ });
+ });
+
+ test('redirects to /login?redirectTo=... when no session exists and url is provided', async () => {
+ const mockLocals = createMockLocalsWithoutSession();
+ const mockUrl = createMockUrlWorkbooksSlug();
+
+ await expect(getLoggedInUser(mockLocals, mockUrl)).rejects.toMatchObject({
+ name: 'Redirect',
+ status: expect.any(Number),
+ location: '/login?redirectTo=%2Fworkbooks%2Fbfs',
+ });
+ });
+ });
+});
diff --git a/src/features/auth/services/session.ts b/src/features/auth/services/session.ts
new file mode 100644
index 000000000..4eb97bf92
--- /dev/null
+++ b/src/features/auth/services/session.ts
@@ -0,0 +1,49 @@
+import { redirect } from '@sveltejs/kit';
+
+import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes';
+
+import { buildLoginPath } from '../utils/login';
+
+/**
+ * Get the current logged-in user or redirect to login.
+ *
+ * **Control flow guarantee:** This function either returns a user object or throws redirect().
+ * It never returns null — if no session exists, redirect() is thrown before returning.
+ * Safe to use with non-null assertion (!) in calling code, as null is unreachable.
+ *
+ * **Future refactor:** Consider separating into:
+ * - getLoggedInUser(locals, url) — current behavior (redirect on no session)
+ * - getLoggedInUserOptional(locals) — returns null on no session
+ * See GitHub issue #XXXX for discussion.
+ *
+ * @param locals - The application locals containing auth and user information
+ * @param url - The current URL; when provided, appends ?redirectTo= so the user returns after login
+ * @returns The logged-in user (never null; redirect is thrown instead)
+ * @throws SvelteKit redirect(307, '/login?redirectTo=...') if session does not exist
+ */
+export const getLoggedInUser = async (
+ locals: App.Locals,
+ url?: URL,
+): Promise => {
+ await ensureSessionOrRedirect(locals, url);
+ const loggedInUser = locals.user;
+
+ if (!loggedInUser) {
+ redirect(TEMPORARY_REDIRECT, buildLoginPath(url));
+ }
+
+ return loggedInUser;
+};
+
+/**
+ * Ensure user has a valid session or redirect to login
+ * @param locals - The application locals containing auth and user information
+ * @param url - The current URL; when provided, appends ?redirectTo= so the user returns after login
+ */
+export const ensureSessionOrRedirect = async (locals: App.Locals, url?: URL): Promise => {
+ const session = await locals.auth.validate();
+
+ if (!session) {
+ redirect(TEMPORARY_REDIRECT, buildLoginPath(url));
+ }
+};
diff --git a/src/features/auth/utils/login.test.ts b/src/features/auth/utils/login.test.ts
new file mode 100644
index 000000000..e12329cc8
--- /dev/null
+++ b/src/features/auth/utils/login.test.ts
@@ -0,0 +1,31 @@
+import { expect, test, describe } from 'vitest';
+
+import { buildLoginPath } from './login';
+
+const createUrl = (path: string) => new URL(`http://localhost:5174${path}`);
+
+describe('buildLoginPath', () => {
+ describe('without url', () => {
+ test('returns base login path', () => {
+ expect(buildLoginPath()).toBe('/login');
+ });
+ });
+
+ describe('with url', () => {
+ test('appends redirectTo with encoded pathname', () => {
+ expect(buildLoginPath(createUrl('/workbooks/bfs'))).toBe(
+ '/login?redirectTo=%2Fworkbooks%2Fbfs',
+ );
+ });
+
+ test('appends redirectTo including query string', () => {
+ expect(buildLoginPath(createUrl('/workbooks?tab=solution'))).toBe(
+ '/login?redirectTo=%2Fworkbooks%3Ftab%3Dsolution',
+ );
+ });
+
+ test('returns redirectTo for root path', () => {
+ expect(buildLoginPath(createUrl('/'))).toBe('/login?redirectTo=%2F');
+ });
+ });
+});
diff --git a/src/features/auth/utils/login.ts b/src/features/auth/utils/login.ts
new file mode 100644
index 000000000..192840e32
--- /dev/null
+++ b/src/features/auth/utils/login.ts
@@ -0,0 +1,15 @@
+import { LOGIN_PAGE } from '$lib/constants/navbar-links';
+
+/**
+ * Builds the login path with an optional redirect parameter.
+ * @param redirectTo - The decoded path to redirect back to after login; if not provided, returns the base login path
+ * @returns The login path with optional redirectTo query parameter
+ */
+export function buildLoginPath(redirectTo?: string | URL | null): string {
+ if (!redirectTo) {
+ return LOGIN_PAGE;
+ }
+
+ const pathname = redirectTo instanceof URL ? redirectTo.pathname + redirectTo.search : redirectTo;
+ return `${LOGIN_PAGE}?redirectTo=${encodeURIComponent(pathname)}`;
+}
diff --git a/src/features/auth/utils/signup.test.ts b/src/features/auth/utils/signup.test.ts
new file mode 100644
index 000000000..4b031735e
--- /dev/null
+++ b/src/features/auth/utils/signup.test.ts
@@ -0,0 +1,31 @@
+import { expect, test, describe } from 'vitest';
+
+import { buildSignupPath } from './signup';
+
+describe('buildSignupPath', () => {
+ describe('without redirectTo', () => {
+ test('returns base signup path when undefined', () => {
+ expect(buildSignupPath()).toBe('/signup');
+ });
+
+ test('returns base signup path when null', () => {
+ expect(buildSignupPath(null)).toBe('/signup');
+ });
+ });
+
+ describe('with redirectTo', () => {
+ test('appends redirectTo with encoded pathname', () => {
+ expect(buildSignupPath('/workbooks/bfs')).toBe('/signup?redirectTo=%2Fworkbooks%2Fbfs');
+ });
+
+ test('appends redirectTo including query string', () => {
+ expect(buildSignupPath('/workbooks?tab=solution')).toBe(
+ '/signup?redirectTo=%2Fworkbooks%3Ftab%3Dsolution',
+ );
+ });
+
+ test('returns redirectTo for root path', () => {
+ expect(buildSignupPath('/')).toBe('/signup?redirectTo=%2F');
+ });
+ });
+});
diff --git a/src/features/auth/utils/signup.ts b/src/features/auth/utils/signup.ts
new file mode 100644
index 000000000..2f83dc5ae
--- /dev/null
+++ b/src/features/auth/utils/signup.ts
@@ -0,0 +1,15 @@
+import { SIGNUP_PAGE } from '$lib/constants/navbar-links';
+
+/**
+ * Builds the signup path with an optional redirect parameter.
+ * @param redirectTo - The decoded path to redirect back to after signup; if not provided, returns the base signup path
+ * @returns The signup path with optional redirectTo query parameter
+ */
+export function buildSignupPath(redirectTo?: string | URL | null): string {
+ if (!redirectTo) {
+ return SIGNUP_PAGE;
+ }
+
+ const pathname = redirectTo instanceof URL ? redirectTo.pathname + redirectTo.search : redirectTo;
+ return `${SIGNUP_PAGE}?redirectTo=${encodeURIComponent(pathname)}`;
+}
diff --git a/src/lib/components/AuthForm.svelte b/src/lib/components/AuthForm.svelte
index c0e691eda..b06d72562 100644
--- a/src/lib/components/AuthForm.svelte
+++ b/src/lib/components/AuthForm.svelte
@@ -28,7 +28,7 @@
submitButtonLabel: string;
confirmationMessage: string;
alternativePageName: string;
- alternativePageLink: '/login' | '/signup';
+ alternativePageLink: string;
}
let {
diff --git a/src/lib/components/SubmissionStatus/UpdatingDropdown.svelte b/src/lib/components/SubmissionStatus/UpdatingDropdown.svelte
index a6ed99a42..d8e5dfd2b 100644
--- a/src/lib/components/SubmissionStatus/UpdatingDropdown.svelte
+++ b/src/lib/components/SubmissionStatus/UpdatingDropdown.svelte
@@ -20,7 +20,7 @@
-->
Safe content
',
+ expected: '<script>alert("XSS")</script>Safe content
',
+ },
+ {
+ rawUrl: 'https://.hatenablog.com',
+ expected: 'https://<script>alert("XSS")</script>.hatenablog.com',
+ },
+ ];
+
+ runTests('sanitizeUrl', testCases, ({ rawUrl, expected }: TestCaseForUrlSanitization) => {
+ expect(sanitizeUrl(rawUrl)).toEqual(expected);
+ });
+ });
+
+ function runTests(
+ testName: string,
+ testCases: TestCasesForUrlSanitization,
+ testFunction: (testCase: TestCaseForUrlSanitization) => void,
+ ) {
+ test.each(testCases)(`${testName}(rawUrl: $rawUrl)`, testFunction);
+ }
+ });
+
+ describe('isSameOriginRedirect', () => {
+ const requestOrigin = 'http://localhost:5174';
+
+ describe('successful cases (should return true)', () => {
+ test('accepts relative path without query', () => {
+ expect(isSameOriginRedirect('/workbooks/bfs', requestOrigin)).toBe(true);
+ });
+
+ test('accepts relative path with query string', () => {
+ expect(isSameOriginRedirect('/workbooks/bfs?tab=foo', requestOrigin)).toBe(true);
+ });
+
+ test('accepts root path', () => {
+ expect(isSameOriginRedirect('/', requestOrigin)).toBe(true);
+ });
+
+ test('accepts path with encoded forward slashes (%2F) as path segment', () => {
+ expect(isSameOriginRedirect('/%2F%2Fexternal.example.com', requestOrigin)).toBe(true);
+ });
+
+ test('accepts path with path traversal attempts (normalized by URL parser)', () => {
+ expect(isSameOriginRedirect('/../../../external.example.com', requestOrigin)).toBe(true);
+ });
+
+ test('accepts path with hash fragment', () => {
+ expect(isSameOriginRedirect('/workbooks#section', requestOrigin)).toBe(true);
+ });
+ });
+
+ describe('failure cases (should return false)', () => {
+ test('rejects absolute URL to different origin', () => {
+ expect(isSameOriginRedirect('https://danger.com', requestOrigin)).toBe(false);
+ });
+
+ test('rejects protocol-relative URL (security issue)', () => {
+ expect(isSameOriginRedirect('//danger.com', requestOrigin)).toBe(false);
+ });
+
+ test('rejects URL with userinfo attempt (username@host)', () => {
+ expect(isSameOriginRedirect('https://app.com@danger.com/path', requestOrigin)).toBe(false);
+ });
+
+ test('rejects javascript: URL (opaque origin)', () => {
+ expect(isSameOriginRedirect('javascript:alert()', requestOrigin)).toBe(false);
+ });
+
+ test('rejects data: URL (opaque origin)', () => {
+ expect(isSameOriginRedirect('data:text/html,', requestOrigin)).toBe(
+ false,
+ );
+ });
+
+ test('rejects absolute URL with different protocol', () => {
+ expect(isSameOriginRedirect('https://localhost:5174', 'http://localhost:5174')).toBe(false);
+ });
+
+ test('rejects absolute URL with different port', () => {
+ expect(isSameOriginRedirect('http://localhost:5175', requestOrigin)).toBe(false);
+ });
+ });
+ });
+});
diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts
index ec76453ad..d01476537 100644
--- a/src/lib/utils/url.ts
+++ b/src/lib/utils/url.ts
@@ -44,3 +44,19 @@ export function isValidUrlSlug(slug: string): boolean {
export function sanitizeUrl(rawUrl: string) {
return xss(rawUrl);
}
+
+/**
+ * Validates if a redirect target is safe (same origin) to prevent open redirect vulnerabilities.
+ *
+ * @param redirectTarget - The target URL to redirect to (relative or absolute)
+ * @param requestOrigin - The origin of the current request (e.g., 'http://localhost:5174')
+ * @returns A boolean indicating whether the redirect target is same-origin as the request
+ */
+export function isSameOriginRedirect(redirectTarget: string, requestOrigin: string): boolean {
+ try {
+ const targetUrl = new URL(redirectTarget, requestOrigin);
+ return targetUrl.origin === requestOrigin;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/routes/(admin)/account_transfer/+page.server.ts b/src/routes/(admin)/account_transfer/+page.server.ts
index 5063dff8f..d07f204af 100644
--- a/src/routes/(admin)/account_transfer/+page.server.ts
+++ b/src/routes/(admin)/account_transfer/+page.server.ts
@@ -1,33 +1,20 @@
// See:
// https://superforms.rocks/get-started
-import { redirect, type Actions } from '@sveltejs/kit';
+import { type Actions } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
-import * as userService from '$lib/services/users';
import * as taskResultService from '$lib/services/task_results';
import { accountTransferSchema } from '$lib/zod/schema';
import type { FloatingMessage } from '$lib/types/floating_message';
-import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes';
-import { LOGIN_PAGE } from '$lib/constants/navbar-links';
-import { Roles } from '$lib/types/user';
+import { validateAdminAccess } from '$features/auth/services/admin_access';
let accountTransferMessages: FloatingMessage[] = [];
-export async function load({ locals }) {
- const session = await locals.auth.validate();
-
- if (!session) {
- throw redirect(TEMPORARY_REDIRECT, LOGIN_PAGE);
- }
-
- const user = await userService.getUser(session?.user.username as string);
-
- if (user && user.role !== Roles.ADMIN) {
- throw redirect(TEMPORARY_REDIRECT, LOGIN_PAGE);
- }
+export async function load({ locals, url }) {
+ await validateAdminAccess(locals, url);
const form = await superValidate(null, zod4(accountTransferSchema));
@@ -44,7 +31,9 @@ export async function load({ locals }) {
}
export const actions: Actions = {
- default: async ({ request }) => {
+ default: async ({ request, locals, url }) => {
+ await validateAdminAccess(locals, url);
+
try {
const form = await superValidate(request, zod4(accountTransferSchema));
diff --git a/src/routes/(admin)/tags/+page.server.ts b/src/routes/(admin)/tags/+page.server.ts
index c119c71df..b720872ce 100644
--- a/src/routes/(admin)/tags/+page.server.ts
+++ b/src/routes/(admin)/tags/+page.server.ts
@@ -3,22 +3,11 @@ import { redirect, type Actions } from '@sveltejs/kit';
import type { Tag } from '$lib/types/tag';
import * as tagService from '$lib/services/tags';
-import * as userService from '$lib/services/users';
-//import { sha256 } from '$lib/utils/hash';
+import { validateAdminAccess } from '$features/auth/services/admin_access';
-import { Roles } from '$lib/types/user';
-
-export async function load({ locals }) {
- const session = await locals.auth.validate();
- if (!session) {
- redirect(302, '/login');
- }
-
- const user = await userService.getUser(session?.user.username as string);
- if (user?.role !== Roles.ADMIN) {
- redirect(302, '/login');
- }
+export async function load({ locals, url }) {
+ await validateAdminAccess(locals, url);
const tags = await tagService.getTags();
diff --git a/src/routes/(admin)/tags/[tag_id]/+page.server.ts b/src/routes/(admin)/tags/[tag_id]/+page.server.ts
index 51d2371f8..e83c37ed1 100644
--- a/src/routes/(admin)/tags/[tag_id]/+page.server.ts
+++ b/src/routes/(admin)/tags/[tag_id]/+page.server.ts
@@ -1,35 +1,19 @@
-import { redirect } from '@sveltejs/kit';
+import type { Tasks } from '$lib/types/task';
+import type { Tags } from '$lib/types/tag';
-//import type { Roles } from '$lib/types/user';
-import type { Tag } from '$lib/types/tag';
-import type { Task } from '$lib/types/task';
import * as tagService from '$lib/services/tags';
import * as taskTagService from '$lib/services/task_tags';
-import * as userService from '$lib/services/users';
-import { Roles } from '$lib/types/user';
-export async function load({ locals, params }) {
- const session = await locals.auth.validate();
- if (!session) {
- redirect(302, '/login');
- }
+import { validateAdminAccess } from '$features/auth/services/admin_access';
- const user = await userService.getUser(session?.user.username as string);
- if (user?.role !== Roles.ADMIN) {
- redirect(302, '/login');
- }
- const tags: Tag[] = await tagService.getTag(params.tag_id as string);
- const tasks: Task[] = await taskTagService.getTasks(params.tag_id as string);
+export async function load({ locals, params, url }) {
+ await validateAdminAccess(locals, url);
- //Jsonから、必要なtag
-
- console.log(tags[0]);
- console.log(user.role);
- console.log(session?.user.role);
+ const tasks: Tasks = await taskTagService.getTasks(params.tag_id as string);
+ const tags: Tags = await tagService.getTag(params.tag_id as string);
return {
- tag: tags[0],
tasks: tasks,
- isAdmin: user?.role !== Roles.ADMIN,
+ tag: tags[0],
};
}
diff --git a/src/routes/(admin)/tasks/+page.server.ts b/src/routes/(admin)/tasks/+page.server.ts
index e6c582bd5..4590f9dad 100644
--- a/src/routes/(admin)/tasks/+page.server.ts
+++ b/src/routes/(admin)/tasks/+page.server.ts
@@ -1,6 +1,5 @@
import { redirect, type Actions } from '@sveltejs/kit';
-import { Roles } from '$lib/types/user';
import type { Contests, ContestForImport, ContestsForImport } from '$lib/types/contest';
import {
type Task,
@@ -11,19 +10,15 @@ import {
getTaskGrade,
} from '$lib/types/task';
-import * as taskService from '$lib/services/tasks';
-import * as userService from '$lib/services/users';
import * as apiClient from '$lib/clients';
+import * as taskService from '$lib/services/tasks';
+import { validateAdminAccess } from '$features/auth/services/admin_access';
-import { isAdmin } from '$lib/utils/authorship';
-import { sha256 } from '$lib/utils/hash';
import { classifyContest } from '$lib/utils/contest';
+import { sha256 } from '$lib/utils/hash';
-import { LOGIN_PAGE } from '$lib/constants/navbar-links';
-import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes.js';
-
-export async function load({ locals }) {
- await validateAdminAccess(locals);
+export async function load({ locals, url }) {
+ await validateAdminAccess(locals, url);
const { contestsForImport, tasksForImport } = await fetchContestsAndTasksFromAPI();
@@ -45,20 +40,6 @@ export async function load({ locals }) {
};
}
-async function validateAdminAccess(locals: App.Locals): Promise {
- const session = await locals.auth.validate();
-
- if (!session) {
- redirect(TEMPORARY_REDIRECT, LOGIN_PAGE);
- }
-
- const user = await userService.getUser(session?.user.username as string);
-
- if (!isAdmin(user?.role as Roles)) {
- redirect(TEMPORARY_REDIRECT, LOGIN_PAGE);
- }
-}
-
async function fetchContestsAndTasksFromAPI(): Promise<{
contestsForImport: ContestsForImport;
tasksForImport: TasksForImport;
@@ -126,7 +107,9 @@ function mergeContestsAndUnregisteredTasks(
}
export const actions: Actions = {
- create: async ({ request }) => {
+ create: async ({ request, locals }) => {
+ await validateAdminAccess(locals);
+
try {
console.log('users->actions->generate');
const formData = await request.formData();
@@ -152,7 +135,9 @@ export const actions: Actions = {
};
},
- update: async ({ request }) => {
+ update: async ({ request, locals }) => {
+ await validateAdminAccess(locals);
+
try {
console.log('users->actions->generate');
const formData = await request.formData();
diff --git a/src/routes/(admin)/tasks/[task_id]/+page.server.ts b/src/routes/(admin)/tasks/[task_id]/+page.server.ts
index 319230dbf..70bf0bbe4 100644
--- a/src/routes/(admin)/tasks/[task_id]/+page.server.ts
+++ b/src/routes/(admin)/tasks/[task_id]/+page.server.ts
@@ -1,33 +1,18 @@
-import { redirect } from '@sveltejs/kit';
+import type { Tasks } from '$lib/types/task';
+import type { Tag } from '$lib/types/tag';
+import type { ImportTaskTag } from '$lib/types/tasktag';
-//import type { Roles } from '$lib/types/user';
-import type { Task } from '$lib/types/task';
import * as taskService from '$lib/services/tasks';
-import * as userService from '$lib/services/users';
-//import * as tagService from '$lib/services/tags';
-import { Roles } from '$lib/types/user';
-import type { ImportTaskTag } from '$lib/types/tasktag';
-import type { Tag } from '$lib/types/tag';
import * as taskTagsApiService from '$lib/services/tasktagsApiService';
import * as taskTagsService from '$lib/services/task_tags';
-export async function load({ locals, params }) {
- const session = await locals.auth.validate();
- if (!session) {
- redirect(302, '/login');
- }
+import { validateAdminAccess } from '$features/auth/services/admin_access';
- const user = await userService.getUser(session?.user.username as string);
- if (user?.role !== Roles.ADMIN) {
- redirect(302, '/login');
- }
- const task: Task[] = await taskService.getTask(params.task_id as string);
+export async function load({ locals, params, url }) {
+ await validateAdminAccess(locals, url);
- //console.log(task);
- //console.log(user.role);
- //console.log(session?.user.role);
+ const task: Tasks = await taskService.getTask(params.task_id as string);
- //jsonデータから必要なTask情報を取り出す。
const importTagsJson = await taskTagsApiService.getTaskTags();
const taskTagsJson = importTagsJson[0].data.filter(
(taskTag: ImportTaskTag) => taskTag.task_id === params.task_id,
@@ -38,11 +23,8 @@ export async function load({ locals, params }) {
taskTags.map(async (tag: Tag) => {
tagMap.set(tag.name, tag);
- //console.log(tag.name, tag)
});
- //console.log(taskTags)
-
if (taskTagsJson.length > 0) {
const importTags: string[] = taskTagsJson[0].tags;
@@ -57,15 +39,10 @@ export async function load({ locals, params }) {
tagMap.set(importTags[i], tmpTag);
}
}
- //console.log(importTags)
}
- //console.log(tagMap)
- //console.log(tagMap.values())
-
return {
task: task[0],
tags: Array.from(tagMap.values()),
- isAdmin: user?.role !== Roles.ADMIN,
};
}
diff --git a/src/routes/(admin)/vote_management/+page.server.ts b/src/routes/(admin)/vote_management/+page.server.ts
index a610f4d56..f36e13721 100644
--- a/src/routes/(admin)/vote_management/+page.server.ts
+++ b/src/routes/(admin)/vote_management/+page.server.ts
@@ -1,15 +1,16 @@
import type { Actions, PageServerLoad } from './$types';
import { type TaskGrade, TaskGrade as TaskGradeEnum } from '$lib/types/task';
+
import { getTasksByTaskId, updateTask } from '$lib/services/tasks';
import {
getAllVoteStatisticsAsArray,
getAllVoteCounters,
} from '$features/votes/services/vote_statistics';
-import { validateAdminAccess } from '../_utils/auth';
+import { validateAdminAccess } from '$features/auth/services/admin_access';
-export const load: PageServerLoad = async ({ locals }) => {
- await validateAdminAccess(locals);
+export const load: PageServerLoad = async ({ locals, url }) => {
+ await validateAdminAccess(locals, url);
const [allStats, tasksMap, allCounters] = await Promise.all([
getAllVoteStatisticsAsArray(),
diff --git a/src/routes/(admin)/workbooks/order/+page.server.ts b/src/routes/(admin)/workbooks/order/+page.server.ts
index df4010a8a..a9a0c0190 100644
--- a/src/routes/(admin)/workbooks/order/+page.server.ts
+++ b/src/routes/(admin)/workbooks/order/+page.server.ts
@@ -5,10 +5,10 @@ import {
createInitialPlacements,
} from '$features/workbooks/services/workbook_placements/crud';
-import { validateAdminAccess } from '../../_utils/auth';
+import { validateAdminAccess } from '$features/auth/services/admin_access';
-export async function load({ locals }) {
- await validateAdminAccess(locals);
+export async function load({ locals, url }) {
+ await validateAdminAccess(locals, url);
const workbooks = await getWorkbooksWithPlacements();
const hasUnplacedWorkbooks = workbooks.some((workbook) => !workbook.placement);
diff --git a/src/routes/(admin)/workbooks/order/+server.ts b/src/routes/(admin)/workbooks/order/+server.ts
index 4e2549d4f..14f23e328 100644
--- a/src/routes/(admin)/workbooks/order/+server.ts
+++ b/src/routes/(admin)/workbooks/order/+server.ts
@@ -4,7 +4,7 @@ import { validateAndUpdatePlacements } from '$features/workbooks/services/workbo
import { updatePlacementsSchema } from '$features/workbooks/zod/schema';
-import { validateAdminAccessForApi } from '../../_utils/auth';
+import { validateAdminAccessForApi } from '$features/auth/services/admin_access';
import { BAD_REQUEST } from '$lib/constants/http-response-status-codes';
diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts
index b48892d11..88ae77c45 100644
--- a/src/routes/(auth)/login/+page.server.ts
+++ b/src/routes/(auth)/login/+page.server.ts
@@ -6,9 +6,11 @@
import { fail, redirect } from '@sveltejs/kit';
import { LuciaError } from 'lucia';
-import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms';
import { auth } from '$lib/server/auth';
+import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms';
+import { isSameOriginRedirect } from '$lib/utils/url';
+
import {
BAD_REQUEST,
SEE_OTHER,
@@ -23,7 +25,7 @@ export const load: PageServerLoad = async ({ locals }) => {
};
export const actions: Actions = {
- default: async ({ request, locals }) => {
+ default: async ({ request, locals, url }) => {
const form = await validateAuthFormWithFallback(request);
if (!form.valid) {
@@ -73,8 +75,15 @@ export const actions: Actions = {
});
}
+ const redirectTo = url.searchParams.get('redirectTo');
+ let destination = HOME_PAGE;
+
+ if (redirectTo && isSameOriginRedirect(redirectTo, url.origin)) {
+ destination = redirectTo;
+ }
+
// redirect to
// make sure you don't throw inside a try/catch block!
- redirect(SEE_OTHER, HOME_PAGE);
+ redirect(SEE_OTHER, destination);
},
};
diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte
index d0195bc54..77e6c7593 100644
--- a/src/routes/(auth)/login/+page.svelte
+++ b/src/routes/(auth)/login/+page.svelte
@@ -1,8 +1,12 @@
diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts
index 9f8b4feb9..ac0c0415b 100644
--- a/src/routes/(auth)/signup/+page.server.ts
+++ b/src/routes/(auth)/signup/+page.server.ts
@@ -7,9 +7,11 @@ import { fail, redirect } from '@sveltejs/kit';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { LuciaError } from 'lucia';
-import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms';
import { auth } from '$lib/server/auth';
+import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms';
+import { isSameOriginRedirect } from '$lib/utils/url';
+
import {
SEE_OTHER,
BAD_REQUEST,
@@ -25,7 +27,7 @@ export const load: PageServerLoad = async ({ locals }) => {
// FIXME: エラー処理に共通部分があるため、リファクタリングをしましょう。
export const actions: Actions = {
- default: async ({ request, locals }) => {
+ default: async ({ request, locals, url }) => {
const form = await validateAuthFormWithFallback(request);
if (!form.valid) {
@@ -84,8 +86,15 @@ export const actions: Actions = {
});
}
+ const redirectTo = url.searchParams.get('redirectTo');
+ let destination = HOME_PAGE;
+
+ if (redirectTo && isSameOriginRedirect(redirectTo, url.origin)) {
+ destination = redirectTo;
+ }
+
// redirect to
// make sure you don't throw inside a try/catch block!
- return redirect(SEE_OTHER, HOME_PAGE);
+ return redirect(SEE_OTHER, destination);
},
};
diff --git a/src/routes/problems/[slug]/+page.server.ts b/src/routes/problems/[slug]/+page.server.ts
index 6f7e3b7d3..32e33e8a9 100644
--- a/src/routes/problems/[slug]/+page.server.ts
+++ b/src/routes/problems/[slug]/+page.server.ts
@@ -1,30 +1,31 @@
-import { fail, type Actions } from '@sveltejs/kit';
-import * as crud from '$lib/services/task_results';
-import { BAD_REQUEST, TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes';
-import { redirect } from '@sveltejs/kit';
+import { fail, type Actions, redirect } from '@sveltejs/kit';
+import * as crud from '$lib/services/task_results';
import { getButtons } from '$lib/services/submission_status';
+import { getLoggedInUser } from '$features/auth/services/session';
-const buttons = await getButtons();
+import { BAD_REQUEST, TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes';
-export async function load({ locals, params }) {
- const session = await locals.auth.validate();
- if (!session) {
- redirect(302, '/login');
- }
+const buttons = await getButtons();
- const taskResult = await crud.getTaskResult(params.slug as string, session?.user.userId);
+export async function load({ locals, params, url }) {
+ // getLoggedInUser either returns a user or throws redirect(); null is unreachable.
+ // Non-null assertion (!) is safe here.
+ const loggedInUser = await getLoggedInUser(locals, url);
+ const taskResult = await crud.getTaskResult(params.slug as string, loggedInUser.id);
return { taskResult: taskResult, buttons: buttons };
}
export const actions = {
- default: async ({ request, params, locals }) => {
+ default: async ({ request, params, locals, url }) => {
const response = await request.formData();
const slug = params.slug as string;
- const session = await locals.auth.validate();
- const userId = session?.user.userId;
+ // Note: getLoggedInUser either returns a user or throws redirect(); null is unreachable.
+ // This action implicitly requires login (redirect on no session).
+ const loggedInUser = await getLoggedInUser(locals, url);
+ const userId = loggedInUser.id;
try {
const submissionStatus = response.get('submissionStatus') as string;
diff --git a/src/routes/users/[username]/+page.server.ts b/src/routes/users/[username]/+page.server.ts
index cfbc98b3f..1af3022c3 100644
--- a/src/routes/users/[username]/+page.server.ts
+++ b/src/routes/users/[username]/+page.server.ts
@@ -1,22 +1,18 @@
//See https://tech-blog.rakus.co.jp/entry/20230209/sveltekit#%E3%82%B9%E3%83%AC%E3%83%83%E3%83%89%E6%8A%95%E7%A8%BF%E7%94%BB%E9%9D%A2
-import { redirect } from '@sveltejs/kit';
-
import * as userService from '$lib/services/users';
import * as taskResultService from '$lib/services/task_results';
import type { Roles } from '$lib/types/user';
import type { TaskResult } from '$lib/types/task';
-import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes';
+import { getLoggedInUser } from '$features/auth/services/session';
-export async function load({ locals, params }) {
- const session = await locals.auth.validate();
- if (!session) {
- redirect(TEMPORARY_REDIRECT, '/login');
- }
+export async function load({ locals, params, url }) {
+ let loggedInUser: Awaited> | undefined;
try {
+ loggedInUser = await getLoggedInUser(locals, url);
const user = await userService.getUser(params.username as string);
if (!user) {
@@ -43,11 +39,11 @@ export async function load({ locals, params }) {
username: user.username,
atcoder_username: user.atCoderAccount?.handle ?? '',
role: user.role as Roles,
- isLoggedIn: session?.user.userId === user.id,
+ isLoggedIn: loggedInUser?.id === user.id,
taskResults: taskResults,
};
} catch (e) {
- console.error('Failed to load user or taskResults: ', session?.user.username);
+ console.error('Failed to load user or taskResults: ', loggedInUser?.name);
console.error(e);
//500を投げたい
//throw redirect(302, '/login');
diff --git a/src/routes/users/edit/+page.server.ts b/src/routes/users/edit/+page.server.ts
index 77e2f44a9..39f884086 100644
--- a/src/routes/users/edit/+page.server.ts
+++ b/src/routes/users/edit/+page.server.ts
@@ -1,5 +1,5 @@
//See https://tech-blog.rakus.co.jp/entry/20230209/sveltekit#%E3%82%B9%E3%83%AC%E3%83%83%E3%83%89%E6%8A%95%E7%A8%BF%E7%94%BB%E9%9D%A2
-import { redirect, fail } from '@sveltejs/kit';
+import { error, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import type { Roles } from '$lib/types/user';
@@ -7,6 +7,8 @@ import type { Roles } from '$lib/types/user';
import * as userService from '$lib/services/users';
import * as verificationService from '$features/account/services/atcoder_verification';
+import { getLoggedInUser } from '$features/auth/services/session';
+
import {
BAD_REQUEST,
UNAUTHORIZED,
@@ -15,20 +17,16 @@ import {
} from '$lib/constants/http-response-status-codes';
export async function load({ locals, url }) {
- const session = await locals.auth.validate();
-
- if (!session) {
- redirect(302, '/login');
- }
+ const loggedInUser = await getLoggedInUser(locals, url);
try {
- const user = await userService.getUser(session?.user.username as string);
+ const user = await userService.getUser(loggedInUser.name);
return {
userId: user?.id as string,
username: user?.username as string,
role: user?.role as Roles,
- isLoggedIn: (session?.user.userId === user?.id) as boolean,
+ isLoggedIn: Boolean(loggedInUser && user && loggedInUser.id === user.id),
atCoderAccount: {
handle: user?.atCoderAccount?.handle ?? '',
validationCode: user?.atCoderAccount?.validationCode ?? '',
@@ -38,9 +36,9 @@ export async function load({ locals, url }) {
message: '',
openAtCoderTab: url.searchParams.get('tab') === 'atcoder',
};
- } catch (error) {
- console.error('User lookup failed during session validation', error);
- redirect(302, '/login');
+ } catch (e) {
+ console.error('Failed to fetch user:', e);
+ error(INTERNAL_SERVER_ERROR, 'ユーザー情報の取得に失敗しました。');
}
}
diff --git a/src/routes/votes/[slug]/+page.svelte b/src/routes/votes/[slug]/+page.svelte
index 021bfa3d9..c2804013e 100644
--- a/src/routes/votes/[slug]/+page.svelte
+++ b/src/routes/votes/[slug]/+page.svelte
@@ -1,6 +1,7 @@