diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md
index 028cf1fc8..c3e93a773 100644
--- a/.claude/rules/svelte-components.md
+++ b/.claude/rules/svelte-components.md
@@ -154,3 +154,46 @@ const map: Partial>> = {
};
// Safe to guard with: {#if map[type]} or if (map[type])
```
+
+## Props — Pass Domain Model Objects; Derive Computed Values Internally
+
+When a component's data comes from a domain model, pass the model object as a single prop
+rather than individual fields. This keeps the call site in sync with the model automatically.
+
+Computed values (status, labels, flags derived from multiple fields) must NOT be props —
+they belong inside the component as `$derived`.
+
+```typescript
+// Bad: individual fields + derived value as prop
+interface Props {
+ handle: string;
+ validationCode: string;
+ isValidated: boolean;
+ status: string; // derived — should not be a prop
+}
+
+// Good: model object as prop; status derived inside
+// (username is from User model; atCoderAccount is from a separate domain model — two props is correct here)
+interface Props {
+ username: string;
+ atCoderAccount: { handle: string; validationCode: string; isValidated: boolean };
+}
+let { username, atCoderAccount }: Props = $props();
+const status = $derived(atCoderAccount.isValidated ? 'validated' : ...);
+```
+
+Call site passes the object directly from `$derived(data.atCoderAccount)`.
+
+## $derived for data.\* Fields in +page.svelte
+
+When reading fields from `data` in a `+page.svelte`, use `$derived` rather than plain assignment:
+
+```typescript
+// Bad: stale after load() re-runs following a form action
+const atCoderAccount = data.atCoderAccount;
+
+// Good: stays in sync when SvelteKit re-runs load() after an action
+const atCoderAccount = $derived(data.atCoderAccount);
+```
+
+`data` is a reactive prop that SvelteKit updates after each form action. A plain assignment captures the initial value only.
diff --git a/.claude/rules/sveltekit.md b/.claude/rules/sveltekit.md
index d7f8bf00b..058816523 100644
--- a/.claude/rules/sveltekit.md
+++ b/.claude/rules/sveltekit.md
@@ -76,3 +76,25 @@ The same pattern applies to `url.searchParams.get()` in `+server.ts` handlers.
## Page Component Props
SvelteKit page components (`+page.svelte`) accept only `data` and `form` as props (`svelte/valid-prop-names-in-kit-pages`). Commented-out features that reference other props are not "dead code" — remove only the violating prop declaration, preserve the feature code.
+
+## load() — Group Related Model Fields as Objects
+
+When a `load()` function returns fields from the same domain model (e.g., `AtCoderAccount`),
+group them as an object rather than flattening to top-level keys.
+Apply default values at this boundary so the page component typically does not need to handle `undefined`.
+
+```typescript
+// Bad: flat, scattered across top-level keys
+atcoder_username: user?.atCoderAccount?.handle ?? '',
+atcoder_validationcode: user?.atCoderAccount?.validationCode ?? '',
+is_validated: user?.atCoderAccount?.isValidated ?? false,
+
+// Good: grouped by model, defaults absorbed here
+atCoderAccount: {
+ handle: user?.atCoderAccount?.handle ?? '',
+ validationCode: user?.atCoderAccount?.validationCode ?? '',
+ isValidated: user?.atCoderAccount?.isValidated ?? false,
+},
+```
+
+When consuming in `+page.svelte`, use `$derived` to maintain reactivity across load() re-runs after form actions (see svelte-components.md).
diff --git a/.claude/rules/testing-e2e.md b/.claude/rules/testing-e2e.md
new file mode 100644
index 000000000..9c394361a
--- /dev/null
+++ b/.claude/rules/testing-e2e.md
@@ -0,0 +1,122 @@
+---
+description: E2E testing rules and patterns (Playwright)
+paths:
+ - '**/*.spec.ts'
+ - 'e2e/**'
+---
+
+# E2E Tests (Playwright)
+
+## No Path Aliases
+
+The `e2e/` directory is outside SvelteKit's build pipeline — `$lib`, `$features`, and other path aliases are not resolved. Define string constant values as local constants with a reference comment:
+
+```typescript
+// Mirrors WorkBookTab.SOLUTION from $features/workbooks/types/workbook
+const TAB_SOLUTION = 'solution';
+```
+
+Avoid importing values from `src/` in E2E test files. Type-only imports (`import type`) are acceptable since they are erased at compile time:
+
+```typescript
+// Bad: runtime import — path alias not resolved in e2e/
+import { TAB_SOLUTION } from '$features/workbooks/types/workbook';
+
+// Good: type-only import — compile-time only
+import type { WorkBookTab } from '$features/workbooks/types/workbook';
+const TAB_SOLUTION: WorkBookTab = 'solution';
+```
+
+## Describe Hierarchy
+
+When a `describe` block for a user role grows large, split it by behavioral dimension rather than adding more flat `test()` calls:
+
+```typescript
+test.describe('logged-in user', () => {
+ test.describe('tab visibility', () => { ... });
+ test.describe('URL parameter handling', () => { ... });
+ test.describe('navigation interactions', () => { ... });
+ test.describe('session state', () => { ... });
+});
+```
+
+## Parameterized Tests
+
+Playwright has no native `test.each`. Use `for...of` loops — the official recommended pattern.
+
+**Single test with a loop** — use when testing a sequence or workflow within one test:
+
+```typescript
+// Mirrors TaskGrade from $lib/types/task — do not import from src/ in E2E files
+const GRADES = ['Q10', 'Q9', 'Q8'] as const;
+
+for (const grade of GRADES) {
+ await gradeButton(page, grade).click();
+ await expect(page).toHaveURL(`?grades=${grade}`);
+}
+```
+
+**Multiple tests from parameters** — use when each parameter represents an independent case:
+
+```typescript
+const GRADES = ['Q10', 'Q9', 'Q8'] as const;
+
+for (const grade of GRADES) {
+ test(`filters by grade ${grade}`, async ({ page }) => {
+ await page.goto('/tasks');
+ await gradeButton(page, grade).click();
+ await expect(page).toHaveURL(`?grades=${grade}`);
+ });
+}
+```
+
+## Assertions
+
+After an interaction that changes element state (active tab, toggle, selection), assert the _new_ state — not just that the element is visible, which may have been true before the interaction. Assert an active CSS class, `aria-selected`, or similar attribute instead of `toBeVisible()`.
+
+## Flowbite Toggle
+
+Flowbite's `Toggle` renders an `sr-only` ` ` inside a ``. Clicking the input directly fails because the visual `` sibling intercepts pointer events. Click the label wrapper instead:
+
+```typescript
+const toggleInput = page.locator('input[aria-label=""]');
+const toggleLabel = page.locator('label:has(input[aria-label=""])');
+
+await toggleLabel.click();
+await expect(toggleInput).toBeChecked({ checked: true });
+```
+
+The same pattern applies to any Flowbite component that visually overlays its native input (e.g. `Checkbox`, `Radio`).
+
+## Strict Mode: Scope Locators to the Content Area
+
+When the navbar and page body both contain a link or button with the same text (e.g., a breadcrumb and a nav link share the same label), `getByRole` in strict mode will find multiple matches and throw. Scope the locator to the page's content container:
+
+```typescript
+// Bad: matches navbar link AND breadcrumb link
+await page.getByRole('link', { name: 'グレード投票' }).click();
+
+// Good: scoped to page content only
+await page.locator('.container').locator('nav').getByRole('link', { name: 'グレード投票' }).click();
+await page.locator('.container').getByRole('link', { name: 'ログイン' }).click();
+```
+
+Use `.container` (page content wrapper) to exclude the global navbar. Prefer the narrowest scope that remains stable — breadcrumb `nav` inside `.container` is more precise than `.container` alone when the link only appears there.
+
+## Conditional Skip Based on Runtime State
+
+When a test depends on DB or session state that may vary across environments (e.g., a user's AtCoder verification status), use `test.skip(condition, reason)` inside the test body instead of a static `test.skip`. This way the test runs automatically when the precondition is met:
+
+```typescript
+test('sees vote grade buttons', async ({ page }) => {
+ await page.goto(url);
+
+ const isUnverified = await page.getByText('AtCoderアカウントの認証が必要です').isVisible();
+ test.skip(isUnverified, 'test user is not AtCoder-verified');
+
+ // assertions below run only when precondition holds
+ await expect(page.locator('form[action="?/voteAbsoluteGrade"]')).toBeVisible();
+});
+```
+
+Prefer this over a hard-coded `test.skip` whenever the condition is observable on the page.
diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md
index a457c660f..24f663018 100644
--- a/.claude/rules/testing.md
+++ b/.claude/rules/testing.md
@@ -57,20 +57,6 @@ E2E test files must use the `.spec.ts` extension. `playwright.config.ts` matches
- Use `toBe(true)` / `toBe(false)` over `toBeTruthy()` / `toBeFalsy()`
- For DB query tests, assert `orderBy`, `include`, and other significant parameters with `expect.objectContaining` — not just `where`. When a returned field (e.g. `authorName`) depends on an `include` relation, that `include` clause must be part of the assertion, or a regression in the query shape will go undetected
- Enum membership: `in` traverses the prototype chain; use `Object.hasOwn(Enum, value)` instead
-- **E2E state transitions**: after an interaction that changes element state (active tab, toggle, selection), assert the _new_ state — not just that the element is visible, which may have been true before the interaction. Assert an active CSS class, `aria-selected`, or similar attribute instead of `toBeVisible()`
-
-## Cleanup in Tests
-
-Wrap DB-mutating cleanup in `try/finally` — a failing assertion skips cleanup and contaminates later tests:
-
-```typescript
-try {
- await doSomething();
- expect(result).toBe(expected);
-} finally {
- await restoreState();
-}
-```
## Test Data
@@ -79,9 +65,22 @@ try {
- After `.filter()` on fixtures, verify actual contents — same ID may refer to a different entity after fixture updates
- **Description ↔ code path alignment**: when a test name describes a specific scenario (e.g. "tie-break"), verify the fixture actually exercises that code path. A test that passes without reaching the branch it claims to cover gives false confidence
-## Mock Helpers
+## Coverage
+
+- Run `pnpm coverage` for coverage report
+- Target: 80% lines, 80% branches
+
+## Test Order Mirrors Source Order
+
+Order `describe` blocks in service and utils test files to match the declaration order of functions in the source file. Misalignment makes it harder to cross-reference tests and implementation.
+
+## Service Layer Unit Tests
-Extract repeated mock patterns into helpers in the test file. For Prisma service tests, define the return type alias once and use it across all helpers:
+Service tests mock Prisma via `vi.mock('$lib/server/database', ...)` — no real DB mutations occur.
+
+### Mock Helpers
+
+Extract repeated mock patterns into helpers in the test file. Define the return type alias once and use it across all helpers:
```typescript
type PrismaWorkBook = Awaited>;
@@ -104,27 +103,24 @@ function mockCount(value: number) {
Extract `mockFindUnique`, `mockFindMany`, and `mockCount` as the standard trio for service tests that touch a single Prisma model. Add `mockCreate`, `mockTransaction`, and `mockDelete` when those operations are also tested.
-## Component Vitest Unit Tests
+### Cleanup for Integration Tests and Tests with Real Side Effects
-Omit Vitest unit tests for a Svelte component when **both** conditions hold:
+This does not apply to standard service layer unit tests that use Prisma mocks.
-1. The component is template-only (no logic beyond prop bindings and basic conditionals)
-2. The component is covered by E2E tests
-
-When a component contains extracted logic (e.g. derived values, event handlers, utility calls), add unit tests for that logic in the nearest `utils/` file instead of testing the component directly.
-
-## Testing Extracted Utilities
-
-- Add tests at extraction time, not later
-- For URL manipulation: assert the original URL is not mutated
-- For multi-column operations (e.g., DnD): assert both source and destination columns
+If a test performs real DB mutations, file system changes, external API calls, or other stateful side effects that persist beyond the test (e.g., integration tests, seed scripts), wrap assertions in `try/finally` — a failing assertion skips cleanup and contaminates later tests:
-## Coverage
+```typescript
+try {
+ await doSomething();
+ expect(result).toBe(expected);
+} finally {
+ await restoreState();
+}
+```
-- Run `pnpm coverage` for coverage report
-- Target: 80% lines, 70% branches
+This is not needed for standard service unit tests that use Prisma mocks.
-## Service Layer Split for Testability
+### File Split for Testability
When a service file mixes DB operations and pure functions, split it into two files:
@@ -133,64 +129,23 @@ When a service file mixes DB operations and pure functions, split it into two fi
Stop the split if internal helpers (e.g. `fetchUnplacedWorkbooks`) would be fragmented across files — cohesion matters more than the split itself.
-## HTTP Mocking
-
-Use Nock for external HTTP calls. See `src/test/lib/clients/` for examples.
-
-## Test Order Mirrors Source Order
-
-Order `describe` blocks in service and utils test files to match the declaration order of functions in the source file. Misalignment makes it harder to cross-reference tests and implementation.
-
-## E2E Tests
-
-### No Path Aliases
-
-The `e2e/` directory is outside SvelteKit's build pipeline — `$lib`, `$features`, and other path aliases are not resolved. Define URL string values as local constants with a reference comment:
-
-```typescript
-// Mirrors WorkBookTab.SOLUTION from $features/workbooks/types/workbook
-const TAB_SOLUTION = 'solution';
-```
-
-Avoid importing types from `src/` in E2E test files.
-
-### Describe Hierarchy
-
-When a `describe` block for a user role grows large, split it by behavioral dimension rather than adding more flat `test()` calls:
-
-```typescript
-test.describe('logged-in user', () => {
- test.describe('tab visibility', () => { ... });
- test.describe('URL parameter handling', () => { ... });
- test.describe('navigation interactions', () => { ... });
- test.describe('session state', () => { ... });
-});
-```
-
-### Parameterized Tests
+## Component Vitest Unit Tests
-Playwright has no native `test.each`. Use `for...of` loops — the official recommended pattern:
+E2E tests are complementary to, not a substitute for, unit tests. Add Vitest unit tests for any component logic (derived values, event handlers, utility calls) by extracting it to the nearest `utils/` file and testing there.
-```typescript
-// Mirrors TaskGrade from $lib/types/task — do not import from src/ in E2E files
-const GRADES = ['Q10', 'Q9', 'Q8'] as const;
+You may omit a component-level Vitest test when **both** conditions hold:
-for (const grade of GRADES) {
- await gradeButton(grade).click();
- await expect(page).toHaveURL(`?grades=${grade}`);
-}
-```
+1. The component is template-only (no logic beyond prop bindings and simple `{#if}`/`{#each}` blocks that only render — no inline function calls, ternaries with side effects, derived computations, or nested logic)
+2. The component's rendering paths are covered by E2E tests
-### Flowbite Toggle
+When a component contains extracted logic (e.g. derived values, event handlers, utility calls), add unit tests for that logic in the nearest `utils/` file instead of testing the component directly.
-Flowbite's `Toggle` renders an `sr-only` ` ` inside a ``. Clicking the input directly fails because the visual `` sibling intercepts pointer events. Click the label wrapper instead:
+## Testing Extracted Utilities
-```typescript
-const toggleInput = page.locator('input[aria-label=""]');
-const toggleLabel = page.locator('label:has(input[aria-label=""])');
+- Add tests at extraction time, not later
+- For URL manipulation: assert the original URL is not mutated
+- For multi-column operations (e.g., DnD): assert both source and destination columns
-await toggleLabel.click();
-await expect(toggleInput).toBeChecked({ checked: true });
-```
+## HTTP Mocking
-The same pattern applies to any Flowbite component that visually overlays its native input (e.g. `Checkbox`, `Radio`).
+Use Nock for external HTTP calls. See `src/test/lib/clients/` for examples.
diff --git a/.env.example b/.env.example
index 1e105cd36..3f811d189 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,3 @@
-# AtCoder affiliation confirmation API endpoint
-# Set this to the actual endpoint URL in your .env file (do not commit real URLs here)
+# AtCoder affiliation confirmation API endpoint (NoviSteps organization crawler)
+# See team documentation for the actual URL.
CONFIRM_API_URL=https://your-confirm-api-endpoint.example.com/confirm
diff --git a/e2e/custom_colors.spec.ts b/e2e/custom_colors.spec.ts
index 12089982f..093fde77d 100644
--- a/e2e/custom_colors.spec.ts
+++ b/e2e/custom_colors.spec.ts
@@ -72,7 +72,7 @@ test.describe('Custom colors for TailwindCSS v4 configuration', () => {
.join('\n');
// Verify xs breakpoint media query is generated (26.25rem = 420px)
- // @media(min-width:26.25rem) confirms the xs breakpoint is defined correctly
- expect(allCss).toMatch(/@media\(min-width:26\.25rem\)/);
+ // TailwindCSS v4 outputs range syntax: @media (width>=26.25rem) or @media (width >= 26.25rem)
+ expect(allCss).toMatch(/@media\s*\(width\s*>=\s*26\.25rem\)/);
});
});
diff --git a/e2e/votes.spec.ts b/e2e/votes.spec.ts
index a00298a0d..17bc86914 100644
--- a/e2e/votes.spec.ts
+++ b/e2e/votes.spec.ts
@@ -79,13 +79,16 @@ test.describe('vote detail page (/votes/[slug])', () => {
test('sees login prompt instead of vote buttons', async ({ page }) => {
await navigateToFirstVoteDetailPage(page);
- await expect(page.getByText('投票するにはログインが必要です')).toBeVisible({
+
+ const content = page.locator('.container');
+
+ await expect(content.getByText('投票するにはログインが必要です')).toBeVisible({
timeout: TIMEOUT,
});
- await expect(page.getByRole('link', { name: 'ログイン' })).toBeVisible({
+ await expect(content.getByRole('link', { name: 'ログイン' })).toBeVisible({
timeout: TIMEOUT,
});
- await expect(page.getByRole('link', { name: 'アカウント作成' })).toBeVisible({
+ await expect(content.getByRole('link', { name: 'アカウント作成' })).toBeVisible({
timeout: TIMEOUT,
});
});
@@ -98,7 +101,11 @@ test.describe('vote detail page (/votes/[slug])', () => {
test('breadcrumb link navigates back to /votes', async ({ page }) => {
await navigateToFirstVoteDetailPage(page);
- await page.getByRole('link', { name: 'グレード投票' }).click();
+ await page
+ .locator('.container')
+ .locator('nav')
+ .getByRole('link', { name: 'グレード投票' })
+ .click();
await expect(page).toHaveURL(VOTES_LIST_URL, { timeout: TIMEOUT });
});
});
@@ -115,8 +122,18 @@ test.describe('vote detail page (/votes/[slug])', () => {
test('sees vote grade buttons', async ({ page }) => {
await navigateToFirstVoteDetailPage(page);
- // Vote form with grade buttons is rendered for logged-in users
- await expect(page.locator('form[action="?/voteAbsoluteGrade"]')).toBeVisible({
+
+ // Skip if the test user is not AtCoder-verified.
+ // Wait for either the vote form or the unverified message to appear before deciding.
+ const voteForm = page.locator('form[action="?/voteAbsoluteGrade"]');
+ const unverifiedMessage = page.getByText('AtCoderアカウントの認証が必要です');
+ await expect(voteForm.or(unverifiedMessage)).toBeVisible({ timeout: TIMEOUT });
+ const isUnverified = await unverifiedMessage.isVisible();
+ test.skip(isUnverified, 'test user is not AtCoder-verified');
+
+ // Explicit check: voteForm is already guaranteed visible by the or() wait above,
+ // but this documents the expected state for verified users.
+ await expect(voteForm).toBeVisible({
timeout: TIMEOUT,
});
// The grade buttons should include Q11 (11Q)
@@ -140,10 +157,10 @@ test.describe('vote management page (/vote_management)', () => {
await expect(page).toHaveURL('/login', { timeout: TIMEOUT });
});
- test('non-admin user is redirected to /login', async ({ page }) => {
+ test('non-admin user is redirected to /', async ({ page }) => {
await loginAsUser(page);
await page.goto(VOTE_MANAGEMENT_URL);
- await expect(page).toHaveURL('/login', { timeout: TIMEOUT });
+ await expect(page).toHaveURL('/', { timeout: TIMEOUT });
});
test.describe('admin user', () => {
diff --git a/src/features/account/components/settings/AtCoderVerificationForm.svelte b/src/features/account/components/settings/AtCoderVerificationForm.svelte
index 6fbcde91a..319358776 100644
--- a/src/features/account/components/settings/AtCoderVerificationForm.svelte
+++ b/src/features/account/components/settings/AtCoderVerificationForm.svelte
@@ -1,6 +1,11 @@
@@ -64,18 +79,13 @@
AtCoder IDを入力し、本人確認用の文字列を生成してください。
-
+
AtCoder ID
-
+
@@ -91,24 +101,19 @@
-
+
-
-
+
+
-
+
本人確認用の文字列
-
+
{#snippet right()}
{/snippet}
@@ -120,8 +125,8 @@
-
-
+
+
@@ -132,12 +137,12 @@
本人確認済
-
+
-
-
+
+
diff --git a/src/features/tasks/components/contest-table/TaskTable.svelte b/src/features/tasks/components/contest-table/TaskTable.svelte
index 1276e11fd..821491b79 100644
--- a/src/features/tasks/components/contest-table/TaskTable.svelte
+++ b/src/features/tasks/components/contest-table/TaskTable.svelte
@@ -37,10 +37,11 @@
interface Props {
taskResults: TaskResults;
isLoggedIn: boolean;
+ isAtCoderVerified: boolean;
voteResults: VoteStatisticsMap;
}
- let { taskResults, isLoggedIn, voteResults }: Props = $props();
+ let { taskResults, isLoggedIn, isAtCoderVerified, voteResults }: Props = $props();
// Prepare contest table provider based on the active contest type.
let activeContestType: ContestTableProviderGroups = $derived(activeContestTypeStore.get());
@@ -283,6 +284,7 @@
handleUpdateTaskResult(updatedTask)}
diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte
index 684ad5fc7..242d6e7b2 100644
--- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte
+++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte
@@ -11,6 +11,7 @@
interface Props {
taskResult: TaskResult;
isLoggedIn: boolean;
+ isAtCoderVerified: boolean;
isShownTaskIndex: boolean;
voteResults: VoteStatisticsMap;
onupdate?: (updatedTask: TaskResult) => void; // Ensure to update task result in parent component.
@@ -19,6 +20,7 @@
let {
taskResult,
isLoggedIn,
+ isAtCoderVerified,
isShownTaskIndex,
voteResults,
onupdate = () => {},
@@ -39,7 +41,7 @@
{#snippet taskGradeLabel(taskResult: TaskResult)}
-
+
{/snippet}
{#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)}
diff --git a/src/features/votes/actions/vote_actions.ts b/src/features/votes/actions/vote_actions.ts
index af9e206e0..41ebd438c 100644
--- a/src/features/votes/actions/vote_actions.ts
+++ b/src/features/votes/actions/vote_actions.ts
@@ -4,6 +4,7 @@ import { TaskGrade } from '@prisma/client';
import { upsertVoteGradeTables } from '$features/votes/services/vote_grade';
import {
BAD_REQUEST,
+ FORBIDDEN,
INTERNAL_SERVER_ERROR,
UNAUTHORIZED,
} from '$lib/constants/http-response-status-codes';
@@ -27,6 +28,12 @@ export const voteAbsoluteGrade = async ({
});
}
+ if (!locals.user?.is_validated) {
+ return fail(FORBIDDEN, {
+ message: 'AtCoderアカウントの認証が必要です。',
+ });
+ }
+
const userId = session.user.userId;
const taskIdRaw = formData.get('taskId');
const gradeRaw = formData.get('grade');
diff --git a/src/features/votes/components/VotableGrade.svelte b/src/features/votes/components/VotableGrade.svelte
index 4f8757e6d..a42e50102 100644
--- a/src/features/votes/components/VotableGrade.svelte
+++ b/src/features/votes/components/VotableGrade.svelte
@@ -9,7 +9,7 @@
import { TaskGrade, getTaskGrade, type TaskResult } from '$lib/types/task';
import { getTaskGradeLabel } from '$lib/utils/task';
import { nonPendingGrades } from '$features/votes/utils/grade_options';
- import { SIGNUP_PAGE, LOGIN_PAGE } from '$lib/constants/navbar-links';
+ import { SIGNUP_PAGE, LOGIN_PAGE, EDIT_PROFILE_PAGE } from '$lib/constants/navbar-links';
import { errorMessageStore } from '$lib/stores/error_message';
import GradeLabel from '$lib/components/GradeLabel.svelte';
@@ -18,10 +18,12 @@
interface Props {
taskResult: TaskResult;
isLoggedIn: boolean;
+ // undefined means the prop was not passed — treat as verified to maintain backward compatibility.
+ isAtCoderVerified?: boolean;
estimatedGrade?: string;
}
- let { taskResult, isLoggedIn, estimatedGrade }: Props = $props();
+ let { taskResult, isLoggedIn, isAtCoderVerified, estimatedGrade }: Props = $props();
// 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態)
// PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示。
@@ -35,6 +37,8 @@
// Use task_id as a deterministic component ID to avoid SSR/hydration mismatches.
const componentId = taskResult.task_id;
+ const editProfileHref = `${resolve(EDIT_PROFILE_PAGE)}?tab=atcoder`;
+
let selectedVoteGrade = $state();
let showForm = $state(false);
let formElement = $state(undefined);
@@ -43,7 +47,7 @@
let votedGrade = $state(null);
async function onTriggerClick() {
- if (!isLoggedIn || isOpening) return;
+ if (!isLoggedIn || isAtCoderVerified === false || isOpening) return;
isOpening = true;
try {
const res = await fetch(
@@ -151,7 +155,7 @@
-{#if isLoggedIn}
+{#if isLoggedIn && isAtCoderVerified !== false}
詳細
+{:else if isLoggedIn}
+
+
+
+ AtCoder認証が必要です
+
+
{:else}
+
{/snippet}
{#snippet listByGrade()}
diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts
index 7bb1555f7..abcb5be4b 100644
--- a/src/routes/sitemap.xml/+server.ts
+++ b/src/routes/sitemap.xml/+server.ts
@@ -69,6 +69,8 @@ export const GET: RequestHandler = async () => {
'/users/.*',
'/workbooks/create.*',
'/workbooks/edit/.*',
+ // Vote detail pages (dynamic, not indexed)
+ '/votes/\\[slug\\]',
// Pages for not-logged-in users
'/forgot_password',
// Deprecated page
diff --git a/src/routes/users/edit/+page.server.ts b/src/routes/users/edit/+page.server.ts
index bd8d22c5f..77e2f44a9 100644
--- a/src/routes/users/edit/+page.server.ts
+++ b/src/routes/users/edit/+page.server.ts
@@ -1,15 +1,22 @@
//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 type { Actions } from './$types';
import type { Roles } from '$lib/types/user';
+
import * as userService from '$lib/services/users';
import * as verificationService from '$features/account/services/atcoder_verification';
-import type { Actions } from './$types';
-import { redirect, fail } from '@sveltejs/kit';
-import { BAD_REQUEST, FORBIDDEN } from '$lib/constants/http-response-status-codes';
+import {
+ BAD_REQUEST,
+ UNAUTHORIZED,
+ FORBIDDEN,
+ INTERNAL_SERVER_ERROR,
+} from '$lib/constants/http-response-status-codes';
export async function load({ locals, url }) {
const session = await locals.auth.validate();
+
if (!session) {
redirect(302, '/login');
}
@@ -22,129 +29,164 @@ export async function load({ locals, url }) {
username: user?.username as string,
role: user?.role as Roles,
isLoggedIn: (session?.user.userId === user?.id) as boolean,
- atcoder_username: user?.atCoderAccount?.handle ?? '',
- atcoder_validationcode: user?.atCoderAccount?.validationCode ?? '',
- is_validated: user?.atCoderAccount?.isValidated ?? false,
+ atCoderAccount: {
+ handle: user?.atCoderAccount?.handle ?? '',
+ validationCode: user?.atCoderAccount?.validationCode ?? '',
+ isValidated: user?.atCoderAccount?.isValidated ?? false,
+ },
message_type: '',
message: '',
openAtCoderTab: url.searchParams.get('tab') === 'atcoder',
};
} catch (error) {
- console.error('Not found username: ', session?.user.username, error);
+ console.error('User lookup failed during session validation', error);
redirect(302, '/login');
}
}
-/** Validates the session and checks that the given username matches the logged-in user. */
-async function requireSelf(
- locals: App.Locals,
- username: string,
-): Promise | null> {
- const session = await locals.auth.validate();
- if (!session) {
- return fail(FORBIDDEN, { message: 'Not authenticated.' });
- }
- if (session.user.username !== username) {
- return fail(FORBIDDEN, { message: 'Not authorized.' });
- }
- return null;
-}
-
export const actions: Actions = {
generate: async ({ request, locals }) => {
- const formData = await request.formData();
- const username = formData.get('username')?.toString();
- if (!username) {
- return fail(BAD_REQUEST, { message: 'Username is required.' });
- }
- const authError = await requireSelf(locals, username);
- if (authError) {
- return authError;
+ const parsed = await parseUsernameAndAuthorize(request, locals);
+
+ if (!parsed.ok) {
+ return parsed.error;
}
- const atcoder_username = formData.get('atcoder_username')?.toString();
- if (!atcoder_username) {
+ const { formData, username } = parsed;
+ const handle = formData.get('handle')?.toString();
+
+ if (!handle) {
return fail(BAD_REQUEST, { message: 'AtCoder username is required.' });
}
- const validationCode = await verificationService.generate(username, atcoder_username);
-
- return {
- success: true,
- username,
- atcoder_username,
- atcoder_validationcode: validationCode,
- is_tab_atcoder: true,
- };
+ try {
+ const validationCode = await verificationService.generate(username, handle);
+
+ return {
+ success: true,
+ username,
+ handle,
+ validationCode,
+ isTabAtcoder: true,
+ };
+ } catch (error) {
+ console.error('Failed to generate validation code', error);
+ return fail(INTERNAL_SERVER_ERROR, { message: 'Failed to generate validation code.' });
+ }
},
validate: async ({ request, locals }) => {
- const formData = await request.formData();
- const username = formData.get('username')?.toString();
- if (!username) {
- return fail(BAD_REQUEST, { message: 'Username is required.' });
- }
- const authError = await requireSelf(locals, username);
- if (authError) {
- return authError;
- }
+ const parsed = await parseUsernameAndAuthorize(request, locals);
- const is_validated = await verificationService.validate(username);
+ if (!parsed.ok) {
+ return parsed.error;
+ }
- return {
- success: is_validated,
- message_type: is_validated ? 'green' : 'red',
- message: is_validated
- ? 'Successfully validated.'
- : 'Validation failed. Please check your AtCoder affiliation.',
- };
+ const { username } = parsed;
+
+ try {
+ const isValidated = await verificationService.validate(username);
+
+ return {
+ success: isValidated,
+ message_type: isValidated ? 'green' : 'red',
+ message: isValidated
+ ? 'Successfully validated.'
+ : 'Validation failed. Please check your AtCoder affiliation.',
+ };
+ } catch (error) {
+ console.error('Failed to validate AtCoder account', error);
+ return fail(INTERNAL_SERVER_ERROR, { message: 'Failed to validate AtCoder account.' });
+ }
},
reset: async ({ request, locals }) => {
- const formData = await request.formData();
- const username = formData.get('username')?.toString();
- if (!username) {
- return fail(BAD_REQUEST, { message: 'Username is required.' });
- }
- const authError = await requireSelf(locals, username);
- if (authError) {
- return authError;
+ const parsed = await parseUsernameAndAuthorize(request, locals);
+
+ if (!parsed.ok) {
+ return parsed.error;
}
- const atcoder_username = formData.get('atcoder_username')?.toString() ?? '';
+ const { username } = parsed;
- await verificationService.reset(username);
+ try {
+ await verificationService.reset(username);
- return {
- success: true,
- username,
- atcoder_username,
- atcoder_validationcode: '',
- message_type: 'green',
- message: 'Successfully reset.',
- };
+ return {
+ success: true,
+ username,
+ message_type: 'green',
+ message: 'Successfully reset.',
+ };
+ } catch (error) {
+ console.error('Failed to reset AtCoder account', error);
+ return fail(INTERNAL_SERVER_ERROR, { message: 'Failed to reset AtCoder account.' });
+ }
},
delete: async ({ request, locals }) => {
- const formData = await request.formData();
- const username = formData.get('username')?.toString();
- if (!username) {
- return fail(BAD_REQUEST, { message: 'Username is required.' });
- }
- const authError = await requireSelf(locals, username);
- if (authError) {
- return authError;
- }
+ const parsed = await parseUsernameAndAuthorize(request, locals);
- await userService.deleteUser(username);
- locals.auth.setSession(null); // remove cookie
+ if (!parsed.ok) {
+ return parsed.error;
+ }
- return {
- success: true,
- username,
- atcoder_validationcode: '',
- message_type: 'green',
- message: 'Successfully deleted.',
- };
+ const { username } = parsed;
+
+ try {
+ await userService.deleteUser(username);
+ locals.auth.setSession(null); // remove cookie
+
+ return {
+ success: true,
+ username,
+ message_type: 'green',
+ message: 'Successfully deleted.',
+ };
+ } catch (error) {
+ console.error('Failed to delete user account', error);
+ return fail(INTERNAL_SERVER_ERROR, { message: 'Failed to delete account.' });
+ }
},
};
+
+type ParseResult =
+ | { ok: true; formData: FormData; username: string }
+ | { ok: false; error: ReturnType };
+
+/** Reads username from formData and verifies the request is from the same user. */
+async function parseUsernameAndAuthorize(
+ request: Request,
+ locals: App.Locals,
+): Promise {
+ const formData = await request.formData();
+ const username = formData.get('username')?.toString();
+
+ if (!username) {
+ return { ok: false, error: fail(BAD_REQUEST, { message: 'Username is required.' }) };
+ }
+
+ const authError = await requireSelf(locals, username);
+
+ if (authError) {
+ return { ok: false, error: authError };
+ }
+
+ return { ok: true, formData, username };
+}
+
+/** Validates the session and checks that the given username matches the logged-in user. */
+async function requireSelf(
+ locals: App.Locals,
+ username: string,
+): Promise | null> {
+ const session = await locals.auth.validate();
+
+ if (!session) {
+ return fail(UNAUTHORIZED, { message: 'Not authenticated.' });
+ }
+ if (session.user.username !== username) {
+ return fail(FORBIDDEN, { message: 'Not authorized.' });
+ }
+ return null;
+}
diff --git a/src/routes/users/edit/+page.svelte b/src/routes/users/edit/+page.svelte
index 78d45efa9..5c0801366 100644
--- a/src/routes/users/edit/+page.svelte
+++ b/src/routes/users/edit/+page.svelte
@@ -1,12 +1,11 @@