Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/features/tasks/components/contest-table/TaskTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -283,6 +284,7 @@
<TaskTableBodyCell
{taskResult}
{isLoggedIn}
{isAtCoderVerified}
{voteResults}
isShownTaskIndex={contestTable.displayConfig.isShownTaskIndex}
onupdate={(updatedTask: TaskResult) => handleUpdateTaskResult(updatedTask)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,6 +20,7 @@
let {
taskResult,
isLoggedIn,
isAtCoderVerified,
isShownTaskIndex,
voteResults,
onupdate = () => {},
Expand All @@ -39,7 +41,7 @@
</div>

{#snippet taskGradeLabel(taskResult: TaskResult)}
<VotableGrade {taskResult} {isLoggedIn} {estimatedGrade} />
<VotableGrade {taskResult} {isLoggedIn} {isAtCoderVerified} {estimatedGrade} />
{/snippet}

{#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)}
Expand Down
7 changes: 7 additions & 0 deletions src/features/votes/actions/vote_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,6 +28,12 @@ export const voteAbsoluteGrade = async ({
});
}

if (!locals.user?.is_validated) {
return fail(FORBIDDEN, {
message: 'AtCoderアカウントの認証が必要です。',
});
}
Comment on lines +31 to +35
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verification check conflates “user is not AtCoder-verified” with “locals.user is missing”. Since locals.user is populated in hooks.server.ts by looking up the DB user, a deleted/missing user could yield locals.user === undefined even when session is valid; returning 403 with an AtCoder-verification message would be misleading. Consider explicitly handling !locals.user (e.g., treat it as UNAUTHORIZED/INTERNAL_SERVER_ERROR) and only return FORBIDDEN when the user exists but is_validated is false.

Copilot uses AI. Check for mistakes.

const userId = session.user.userId;
const taskIdRaw = formData.get('taskId');
const gradeRaw = formData.get('grade');
Expand Down
27 changes: 23 additions & 4 deletions src/features/votes/components/VotableGrade.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(集計済み中央値)があればそれを優先表示。
Expand All @@ -35,6 +37,9 @@
// Use task_id as a deterministic component ID to avoid SSR/hydration mismatches.
const componentId = taskResult.task_id;

// @ts-expect-error svelte-check TS2554: AppTypes declaration merging causes RouteId to resolve as string, requiring params. Runtime behavior is correct.
const editProfileHref = resolve(EDIT_PROFILE_PAGE + '?tab=atcoder');

let selectedVoteGrade = $state<TaskGrade>();
let showForm = $state(false);
let formElement = $state<HTMLFormElement | undefined>(undefined);
Expand All @@ -43,7 +48,7 @@
let votedGrade = $state<TaskGrade | null>(null);

async function onTriggerClick() {
if (!isLoggedIn || isOpening) return;
if (!isLoggedIn || isAtCoderVerified === false || isOpening) return;
isOpening = true;
try {
const res = await fetch(
Expand Down Expand Up @@ -151,7 +156,7 @@
</button>

<!-- Dropdown Menu -->
{#if isLoggedIn}
{#if isLoggedIn && isAtCoderVerified !== false}
<Dropdown
triggeredBy={`#update-grade-dropdown-trigger-${componentId}`}
simple
Expand All @@ -174,6 +179,20 @@
>詳細</DropdownItem
>
</Dropdown>
{:else if isLoggedIn}
<!-- Logged in but not AtCoder-verified: prompt user to complete verification -->
<Dropdown
triggeredBy={`#update-grade-dropdown-trigger-${componentId}`}
simple
class="w-48 z-50 border border-gray-200 dark:border-gray-100"
>
<DropdownItem
href={editProfileHref}
class="rounded-md text-sm text-yellow-700 dark:text-yellow-300"
>
AtCoder認証が必要です
</DropdownItem>
</Dropdown>
{:else}
<Dropdown
triggeredBy={`#update-grade-dropdown-trigger-${componentId}`}
Expand Down
37 changes: 16 additions & 21 deletions src/lib/components/AtCoderUserValidationForm.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Label, Input, P } from 'flowbite-svelte';
import ClipboardCopy from '@lucide/svelte/icons/clipboard-copy';

Expand Down Expand Up @@ -40,12 +41,11 @@
status: string;
}

let {
username = $bindable(),
atcoder_username = $bindable(),
atcoder_validationcode = $bindable(),
status,
}: Props = $props();
let { username, atcoder_username, atcoder_validationcode, status }: Props = $props();

// Editable only in 'nothing' step; server is authoritative after each action.
// untrack: prop is the initial seed only — intentional one-time capture.
let editableAtcoderId = $state(untrack(() => atcoder_username));

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

editableAtcoderId is seeded once from atcoder_username and never re-synced. After a successful reset (server clears atcoder_username and status becomes nothing), the input will still show the old AtCoder ID, diverging from server-authoritative state. Consider syncing editableAtcoderId when status becomes nothing (or when atcoder_username changes to an empty string) while still avoiding overwriting during user edits.

Suggested change
// Keep editableAtcoderId in sync with server after a reset:
// when the server clears atcoder_username and status returns to 'nothing',
// reset the local editable value as well.
$effect(() => {
if (status === 'nothing' && atcoder_username === '') {
editableAtcoderId = '';
}
});

Copilot uses AI. Check for mistakes.
// TODO: Add a "Copied!" message when clicking
// WHY: To provide feedback when the copy operation succeeds
Expand All @@ -64,7 +64,7 @@
<P size="base" class="mt-6">AtCoder IDを入力し、本人確認用の文字列を生成してください。</P>

<!-- hiddenでusernameを持つのは共通-->
<Input size="md" type="hidden" name="username" bind:value={username} />
<Input size="md" type="hidden" name="username" value={username} />
<LabelWrapper labelName="ユーザ名" inputValue={username} />

<Label class="flex flex-col gap-2">
Expand All @@ -74,7 +74,7 @@
size="md"
name="atcoder_username"
placeholder="chokudai"
bind:value={atcoder_username}
bind:value={editableAtcoderId}
/>
</Label>

Expand All @@ -91,24 +91,19 @@
</P>

<!-- hiddenでusernameを持つのは共通-->
<Input size="md" type="hidden" name="username" bind:value={username} />
<Input size="md" type="hidden" name="username" value={username} />
<LabelWrapper labelName="ユーザ名" inputValue={username} />

<!-- atcoder_usernameとvalidation_code は編集不可-->
<Input size="md" type="hidden" name="atcoder_username" bind:value={atcoder_username} />
<Input size="md" type="hidden" name="atcoder_username" value={atcoder_username} />
<LabelWrapper labelName="AtCoder ID" inputValue={atcoder_username} />

<Input
size="md"
type="hidden"
name="atcoder_validationcode"
bind:value={atcoder_validationcode}
/>
<Input size="md" type="hidden" name="atcoder_validationcode" value={atcoder_validationcode} />

<Label class="flex flex-col gap-2">
<span>本人確認用の文字列</span>
<div>
<Input size="md" bind:value={atcoder_validationcode}>
<Input size="md" value={atcoder_validationcode}>
{#snippet right()}
<ClipboardCopy class="w-5 h-5" onclick={handleClick} />
{/snippet}
Expand All @@ -120,8 +115,8 @@
</FormWrapper>

<FormWrapper action="?/reset" marginTop="">
<Input size="md" type="hidden" name="username" bind:value={username} />
<Input size="md" type="hidden" name="atcoder_username" bind:value={atcoder_username} />
<Input size="md" type="hidden" name="username" value={username} />
<Input size="md" type="hidden" name="atcoder_username" value={atcoder_username} />

<SubmissionButton labelName="リセット" />
</FormWrapper>
Expand All @@ -132,11 +127,11 @@
<h3 class="text-xl text-center font-medium text-gray-900 dark:text-white">本人確認済</h3>

<!-- hiddenでusernameを持つのは共通-->
<Input size="md" type="hidden" name="username" bind:value={username} />
<Input size="md" type="hidden" name="username" value={username} />
<LabelWrapper labelName="ユーザ名" inputValue={username} />

<!-- atcoder_usernameを表示(変更不可)-->
<Input size="md" type="hidden" name="atcoder_username" bind:value={atcoder_username} />
<Input size="md" type="hidden" name="atcoder_username" value={atcoder_username} />
<LabelWrapper labelName="AtCoder ID" inputValue={atcoder_username} />

<SubmissionButton labelName="リセット" />
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants/navbar-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const FORGOT_PASSWORD_PAGE = `/forgot_password`;
export const WORKBOOKS_PAGE = `/workbooks`;
export const PROBLEMS_PAGE = `/problems`;
export const VOTES_PAGE = `/votes`;
export const EDIT_PROFILE_PAGE = `/users/edit`;

// For Admin
export const IMPORTING_PROBLEMS_PAGE = `/tasks`;
Expand Down
2 changes: 2 additions & 0 deletions src/routes/problems/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ export async function load({ locals, url }) {
voteResults,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
isAtCoderVerified: locals.user?.is_validated === true,
};
} else {
return {
taskResults: (await task_crud.getTaskResults(session?.user.userId)) as TaskResults,
voteResults,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
isAtCoderVerified: locals.user?.is_validated === true,
};
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/problems/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

let isAdmin: boolean = data.isAdmin;
let isLoggedIn: boolean = data.isLoggedIn;
let isAtCoderVerified: boolean = data.isAtCoderVerified;
let voteResults = data.voteResults;

function isActiveTab(currentTab: ActiveProblemListTab): boolean {
Expand Down Expand Up @@ -60,7 +61,7 @@
{/snippet}

{#snippet contestTable()}
<TaskTable {taskResults} {isLoggedIn} {voteResults} />
<TaskTable {taskResults} {isLoggedIn} {isAtCoderVerified} {voteResults} />
{/snippet}

{#snippet listByGrade()}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/users/edit/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Actions } from './$types';

import { redirect } from '@sveltejs/kit';

export async function load({ locals }) {
export async function load({ locals, url }) {
const session = await locals.auth.validate();
if (!session) {
redirect(302, '/login');
Expand All @@ -26,6 +26,7 @@ export async function load({ locals }) {
is_validated: user?.atcoder_validation_status as boolean,
message_type: '',
message: '',
openAtCoderTab: url.searchParams.get('tab') === 'atcoder',
};
} catch (error) {
console.error('Not found username: ', session?.user.username, error);
Expand Down
Loading
Loading