Skip to content

Commit 2fbffe2

Browse files
author
Nils Bars
committed
Add the Vue SPA frontend sources
Vue 3 + Vite + Vuetify app served under `/v2/`. Hosts the public scoreboard, student registration, and key-restore flows backed by `ref/frontend_api/`. Ships the ranking strategies, scoreboard components, routing, theme tokens, and the Dockerfile/entrypoint used by the `ref-spa-frontend` service.
1 parent 31179f1 commit 2fbffe2

30 files changed

Lines changed: 2522 additions & 0 deletions

spa-frontend/Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /spa-frontend
4+
5+
# Copy manifest first for layer caching. package-lock.json is generated on
6+
# first `npm install`; if it is missing we fall back to `npm install` so a
7+
# fresh checkout still boots cleanly.
8+
COPY package.json package-lock.json* ./
9+
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
10+
11+
# Copy source. In dev the host bind-mount shadows everything under
12+
# /spa-frontend except for node_modules (protected by an anonymous volume
13+
# in compose), so host edits are reflected immediately.
14+
COPY . .
15+
16+
EXPOSE 5173
17+
18+
ENTRYPOINT ["./entrypoint.sh"]

spa-frontend/entrypoint.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
# The host bind mount can swap the source tree underneath us; make sure
5+
# node_modules exists before running any npm scripts.
6+
if [ ! -d node_modules ] || [ -z "$(ls -A node_modules 2>/dev/null || true)" ]; then
7+
echo "[spa-frontend] installing deps"
8+
if [ -f package-lock.json ]; then npm ci; else npm install; fi
9+
fi
10+
11+
if [ "${HOT_RELOADING:-false}" = "true" ]; then
12+
echo "[spa-frontend] starting vite dev server (HMR)"
13+
exec npm run dev
14+
else
15+
echo "[spa-frontend] building and starting vite preview"
16+
npm run build
17+
exec npm run preview
18+
fi

spa-frontend/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<link rel="icon" type="image/x-icon" href="/static/favicon.ico" />
7+
<title>REF</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/main.ts"></script>
12+
</body>
13+
</html>

spa-frontend/src/App.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script setup lang="ts">
2+
import { onMounted } from 'vue';
3+
import DefaultLayout from './layouts/DefaultLayout.vue';
4+
import { useNavStore } from './stores/nav';
5+
import { useTheme } from './theme/useTheme';
6+
7+
const nav = useNavStore();
8+
const theme = useTheme();
9+
10+
onMounted(async () => {
11+
theme.init();
12+
await nav.hydrate();
13+
});
14+
</script>
15+
16+
<template>
17+
<DefaultLayout>
18+
<RouterView />
19+
</DefaultLayout>
20+
</template>

spa-frontend/src/api/client.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Tiny fetch wrapper for the SPA.
2+
//
3+
// Every request goes to a relative path; Vite's dev/preview proxy
4+
// forwards /api, /static and /student/download to the Flask `web`
5+
// container. Non-2xx responses throw an ApiError that carries the
6+
// `{error: {form, fields}}` envelope so pages can surface per-field
7+
// validation messages on the right input.
8+
9+
export type FieldErrors = Record<string, string[]>;
10+
11+
export class ApiError extends Error {
12+
status: number;
13+
form: string;
14+
fields: FieldErrors;
15+
16+
constructor(status: number, form: string, fields: FieldErrors = {}) {
17+
super(form);
18+
this.status = status;
19+
this.form = form;
20+
this.fields = fields;
21+
}
22+
}
23+
24+
async function parseError(res: Response): Promise<ApiError> {
25+
let form = `HTTP ${res.status}`;
26+
let fields: FieldErrors = {};
27+
try {
28+
const body = await res.json();
29+
if (body && typeof body === 'object' && body.error) {
30+
if (typeof body.error === 'string') {
31+
form = body.error;
32+
} else if (typeof body.error === 'object') {
33+
if (typeof body.error.form === 'string') form = body.error.form;
34+
if (body.error.fields && typeof body.error.fields === 'object') {
35+
fields = body.error.fields as FieldErrors;
36+
}
37+
}
38+
}
39+
} catch {
40+
/* leave defaults */
41+
}
42+
return new ApiError(res.status, form, fields);
43+
}
44+
45+
async function request<T>(
46+
path: string,
47+
init: RequestInit = {},
48+
): Promise<T> {
49+
const res = await fetch(path, {
50+
...init,
51+
headers: {
52+
Accept: 'application/json',
53+
...(init.body ? { 'Content-Type': 'application/json' } : {}),
54+
...(init.headers || {}),
55+
},
56+
});
57+
if (!res.ok) throw await parseError(res);
58+
return (await res.json()) as T;
59+
}
60+
61+
export function apiGet<T>(path: string): Promise<T> {
62+
return request<T>(path, { method: 'GET' });
63+
}
64+
65+
export function apiPost<T>(path: string, body: unknown): Promise<T> {
66+
return request<T>(path, { method: 'POST', body: JSON.stringify(body) });
67+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { apiGet, apiPost } from './client';
2+
3+
export interface GroupChoice {
4+
name: string;
5+
count: number;
6+
max: number;
7+
full: boolean;
8+
}
9+
10+
export interface RegistrationMeta {
11+
course_name: string;
12+
registration_enabled: boolean;
13+
groups_enabled: boolean;
14+
max_group_size: number;
15+
groups: GroupChoice[];
16+
password_rules: {
17+
min_length: number;
18+
min_classes: number;
19+
};
20+
mat_num_regex: string;
21+
}
22+
23+
export interface KeyResult {
24+
signed_mat: string;
25+
pubkey: string;
26+
privkey: string | null;
27+
pubkey_url: string;
28+
privkey_url: string | null;
29+
}
30+
31+
export interface RegistrationPayload {
32+
mat_num: string;
33+
firstname: string;
34+
surname: string;
35+
password: string;
36+
password_rep: string;
37+
pubkey?: string;
38+
group_name?: string;
39+
}
40+
41+
export function getRegistrationMeta(): Promise<RegistrationMeta> {
42+
return apiGet<RegistrationMeta>('/api/v2/registration/meta');
43+
}
44+
45+
export function submitRegistration(
46+
payload: RegistrationPayload,
47+
): Promise<KeyResult> {
48+
return apiPost<KeyResult>('/api/v2/registration', payload);
49+
}

spa-frontend/src/api/restoreKey.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { apiPost } from './client';
2+
import type { KeyResult } from './registration';
3+
4+
export interface RestoreKeyPayload {
5+
mat_num: string;
6+
password: string;
7+
}
8+
9+
export function restoreKey(payload: RestoreKeyPayload): Promise<KeyResult> {
10+
return apiPost<KeyResult>('/api/v2/restore-key', payload);
11+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<script setup lang="ts">
2+
import { ref } from 'vue';
3+
import type { KeyResult } from '../api/registration';
4+
5+
const props = withDefaults(
6+
defineProps<{ result: KeyResult; variant?: 'register' | 'restore' }>(),
7+
{ variant: 'register' },
8+
);
9+
10+
const pubCopied = ref(false);
11+
const privCopied = ref(false);
12+
13+
async function copy(text: string | null, flag: 'pub' | 'priv') {
14+
if (!text) return;
15+
try {
16+
await navigator.clipboard.writeText(text);
17+
if (flag === 'pub') {
18+
pubCopied.value = true;
19+
setTimeout(() => (pubCopied.value = false), 1500);
20+
} else {
21+
privCopied.value = true;
22+
setTimeout(() => (privCopied.value = false), 1500);
23+
}
24+
} catch {
25+
/* ignore */
26+
}
27+
}
28+
29+
const hasPrivkey = !!props.result.privkey;
30+
</script>
31+
32+
<template>
33+
<v-card class="term-panel" style="padding: 1.5rem">
34+
<div class="term-eyebrow" style="margin-bottom: 0.5rem">
35+
// key material
36+
</div>
37+
<h2 class="term-display term-hot" style="font-size: 1.5rem; margin: 0 0 1rem">
38+
ACCESS GRANTED
39+
</h2>
40+
41+
<!-- Register page: explain how the key got here. -->
42+
<v-alert
43+
v-if="props.variant === 'register' && hasPrivkey"
44+
type="info"
45+
density="compact"
46+
style="margin-bottom: 1.25rem"
47+
>
48+
A keypair was generated for you. Your private key is stored
49+
server-side — if you lose it, you can retrieve it again via
50+
<strong>Restore Key</strong> with your matriculation number and
51+
password.
52+
</v-alert>
53+
<v-alert
54+
v-else-if="props.variant === 'register' && !hasPrivkey"
55+
type="info"
56+
density="compact"
57+
style="margin-bottom: 1.25rem"
58+
>
59+
You provided your own public key. No private key is stored server-side.
60+
</v-alert>
61+
62+
<!-- Restore Key page: the user just re-fetched an existing keypair. -->
63+
<v-alert
64+
v-else-if="props.variant === 'restore' && hasPrivkey"
65+
type="info"
66+
density="compact"
67+
style="margin-bottom: 1.25rem"
68+
>
69+
Your stored keypair is shown below. Download whichever key you need.
70+
</v-alert>
71+
<v-alert
72+
v-else-if="props.variant === 'restore' && !hasPrivkey"
73+
type="info"
74+
density="compact"
75+
style="margin-bottom: 1.25rem"
76+
>
77+
Only your public key is on file — you supplied your own at
78+
registration time, so the private key stays with you.
79+
</v-alert>
80+
81+
<div style="display: flex; flex-direction: column; gap: 1.5rem">
82+
<div>
83+
<div class="term-eyebrow" style="margin-bottom: 0.4rem">
84+
public key
85+
</div>
86+
<v-textarea
87+
:model-value="props.result.pubkey"
88+
readonly
89+
rows="3"
90+
auto-grow
91+
hide-details
92+
/>
93+
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem">
94+
<v-btn
95+
:href="props.result.pubkey_url"
96+
download="id_rsa.pub"
97+
prepend-icon="mdi-download"
98+
size="small"
99+
>
100+
Download id_rsa.pub
101+
</v-btn>
102+
<v-btn
103+
size="small"
104+
:prepend-icon="pubCopied ? 'mdi-check' : 'mdi-content-copy'"
105+
@click="copy(props.result.pubkey, 'pub')"
106+
>
107+
{{ pubCopied ? 'Copied' : 'Copy' }}
108+
</v-btn>
109+
</div>
110+
</div>
111+
112+
<div v-if="hasPrivkey">
113+
<div class="term-eyebrow" style="margin-bottom: 0.4rem">
114+
private key
115+
</div>
116+
<v-textarea
117+
:model-value="props.result.privkey ?? ''"
118+
readonly
119+
rows="6"
120+
auto-grow
121+
hide-details
122+
/>
123+
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem">
124+
<v-btn
125+
v-if="props.result.privkey_url"
126+
:href="props.result.privkey_url"
127+
download="id_rsa"
128+
prepend-icon="mdi-download"
129+
size="small"
130+
>
131+
Download id_rsa
132+
</v-btn>
133+
<v-btn
134+
size="small"
135+
:prepend-icon="privCopied ? 'mdi-check' : 'mdi-content-copy'"
136+
@click="copy(props.result.privkey, 'priv')"
137+
>
138+
{{ privCopied ? 'Copied' : 'Copy' }}
139+
</v-btn>
140+
</div>
141+
</div>
142+
</div>
143+
</v-card>
144+
</template>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
minLength: number;
4+
minClasses: number;
5+
}>();
6+
</script>
7+
8+
<template>
9+
<div class="term-muted" style="font-size: 0.75rem; letter-spacing: 0.05em">
10+
Password must be at least {{ minLength }} characters and use at least
11+
{{ minClasses }} of: digits, uppercase, lowercase, symbols.
12+
</div>
13+
</template>

0 commit comments

Comments
 (0)