From b3ade697cb0c09aa208bab4f51a1163748860601 Mon Sep 17 00:00:00 2001
From: kallelongjuhani
Date: Mon, 11 Aug 2025 09:16:53 +0300
Subject: [PATCH 001/136] feat: `LLM_OPENAI_API_KEY` env variable
---
.env.example | 3 +++
docker-compose.dev.yml | 1 +
frontend/src/lib/server/constants.ts | 3 ++-
3 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index 90babc952..6daafa44b 100644
--- a/.env.example
+++ b/.env.example
@@ -55,6 +55,9 @@ GENERATE_MOCK_DATA_ON_INITIALISE=true
# Used only in development builds
GENERATE_MOCK_DATA_ON_RESTART=false
+# LLM
+LLM_OPENAI_API_KEY=""
+
# Frontend dependencies
# NB! If adding more such urls with different hosts on the server and client, please check whether /frontend/src/routes/[[lang=locale]]/api/cache/+server.ts needs to be updated accordingly
# Used to reach backend instance from a browser
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 1e59563f6..85f23b456 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -65,6 +65,7 @@ services:
DATABASE_SSL_SELF: ${DATABASE_SSL_SELF}
GENERATE_MOCK_DATA_ON_INITIALISE: ${GENERATE_MOCK_DATA_ON_INITIALISE}
GENERATE_MOCK_DATA_ON_RESTART: ${GENERATE_MOCK_DATA_ON_RESTART}
+ LLM_OPENAI_API_KEY: ${LLM_OPENAI_API_KEY}
LOAD_DATA_ON_INITIALISE_FOLDER: ${LOAD_DATA_ON_INITIALISE_FOLDER}
AWS_SES_ACCESS_KEY_ID: ${AWS_SES_ACCESS_KEY_ID}
AWS_SES_SECRET_ACCESS_KEY: ${AWS_SES_SECRET_ACCESS_KEY}
diff --git a/frontend/src/lib/server/constants.ts b/frontend/src/lib/server/constants.ts
index acb48b6b9..fd8a82438 100644
--- a/frontend/src/lib/server/constants.ts
+++ b/frontend/src/lib/server/constants.ts
@@ -11,5 +11,6 @@ export const constants = {
CACHE_DIR: env.CACHE_DIR,
CACHE_TTL: env.CACHE_TTL,
CACHE_LRU_SIZE: env.CACHE_LRU_SIZE,
- CACHE_EXPIRATION_INTERVAL: env.CACHE_EXPIRATION_INTERVAL
+ CACHE_EXPIRATION_INTERVAL: env.CACHE_EXPIRATION_INTERVAL,
+ LLM_OPENAI_API_KEY: env.LLM_OPENAI_API_KEY
};
From 329ab47faffb9d786d6d6a34fb1d3471945542bd Mon Sep 17 00:00:00 2001
From: kallelongjuhani
Date: Tue, 12 Aug 2025 15:15:03 +0300
Subject: [PATCH 002/136] feat/refactor[backend][frontend]: user roles login
and logout routes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implement different user roles for admins and candidates.
Turn the Candidates’ login server action into a generic login api route.
Use the new api route in the candidate/login server action and also
check for correct user role.
These changes are in preparation for the Admin App.
---
.../users-permissions/strapi-server.ts | 122 ++++++++++--------
.../src/functions/setDefaultApiPermissions.ts | 19 ++-
backend/vaa-strapi/src/index.ts | 4 +-
.../vaa-strapi/src/policies/user-is-admin.ts | 16 +++
.../vaa-strapi/types/customStrapiTypes.d.ts | 13 +-
.../strapi/dataWriter/strapiDataWriter.ts | 14 +-
.../api/adapters/strapi/strapiData.type.ts | 9 ++
.../api/adapters/strapi/utils/parseUser.ts | 15 ++-
.../src/lib/api/base/actionResult.type.ts | 3 +-
frontend/src/lib/api/base/dataWriter.type.ts | 13 +-
.../src/lib/api/base/universalApiRoutes.ts | 5 +-
.../src/lib/api/base/universalDataWriter.ts | 8 +-
frontend/src/lib/api/utils/fail.ts | 10 ++
.../src/lib/candidate/utils/loginError.ts | 3 +-
.../src/lib/i18n/translations/en/error.json | 1 +
.../src/lib/i18n/translations/fi/error.json | 1 +
.../src/lib/i18n/translations/sv/error.json | 1 +
.../src/lib/types/generated/translationKey.ts | 1 +
.../[[lang=locale]]/api/auth/login/+server.ts | 81 ++++++++++++
.../api/{candidate => auth}/logout/+server.ts | 2 +
.../candidate/(protected)/+layout.ts | 4 +-
.../candidate/login/+page.server.ts | 40 ++----
.../candidate/login/+page.svelte | 4 +-
23 files changed, 287 insertions(+), 102 deletions(-)
create mode 100644 backend/vaa-strapi/src/policies/user-is-admin.ts
create mode 100644 frontend/src/lib/api/utils/fail.ts
create mode 100644 frontend/src/routes/[[lang=locale]]/api/auth/login/+server.ts
rename frontend/src/routes/[[lang=locale]]/api/{candidate => auth}/logout/+server.ts (86%)
diff --git a/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts b/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts
index 63fc5aff1..b76dfdeaa 100644
--- a/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts
+++ b/backend/vaa-strapi/src/extensions/users-permissions/strapi-server.ts
@@ -3,46 +3,49 @@ import { errors } from '@strapi/utils';
import fs from 'fs';
import * as candidate from './controllers/candidate';
import type { Core, UID } from '@strapi/strapi';
-import { StrapiContext } from '../../../types/customStrapiTypes';
+import { StrapiContext, StrapiRole } from '../../../types/customStrapiTypes';
import { frontendUrl, mailFrom, mailFromName, mailReplyTo } from '../../constants';
const { ValidationError } = errors;
// NB! Before adding permissions here, please make sure you've implemented the appropriate access control for the resource
// Make sure to allow the user access to all publicly available data
+type RoleType = StrapiRole['type'];
const defaultPermissions: Array<{
action: UID.Controller;
- roleType: 'public' | 'authenticated';
+ roleTypes: RoleType[];
}> = [
- { action: 'plugin::users-permissions.candidate.check', roleType: 'public' },
- { action: 'plugin::users-permissions.candidate.register', roleType: 'public' },
- { action: 'plugin::users-permissions.user.me', roleType: 'authenticated' },
- { action: 'plugin::upload.content-api.upload', roleType: 'authenticated' },
- { action: 'plugin::upload.content-api.destroy', roleType: 'authenticated' },
- { action: 'api::candidate.candidate.find', roleType: 'authenticated' },
- { action: 'api::candidate.answers.overwrite', roleType: 'authenticated' },
- { action: 'api::candidate.answers.update', roleType: 'authenticated' },
- { action: 'api::candidate.candidate.findOne', roleType: 'authenticated' },
- { action: 'api::candidate.candidate.update', roleType: 'authenticated' },
- { action: 'api::candidate.properties.update', roleType: 'authenticated' },
- { action: 'api::alliance.alliance.find', roleType: 'authenticated' },
- { action: 'api::alliance.alliance.findOne', roleType: 'authenticated' },
- { action: 'api::constituency.constituency.find', roleType: 'authenticated' },
- { action: 'api::constituency.constituency.findOne', roleType: 'authenticated' },
- { action: 'api::constituency-group.constituency-group.find', roleType: 'authenticated' },
- { action: 'api::constituency-group.constituency-group.findOne', roleType: 'authenticated' },
- { action: 'api::election.election.find', roleType: 'authenticated' },
- { action: 'api::election.election.findOne', roleType: 'authenticated' },
- { action: 'api::nomination.nomination.find', roleType: 'authenticated' },
- { action: 'api::nomination.nomination.findOne', roleType: 'authenticated' },
- { action: 'api::party.party.find', roleType: 'authenticated' },
- { action: 'api::party.party.findOne', roleType: 'authenticated' },
- { action: 'api::question.question.find', roleType: 'authenticated' },
- { action: 'api::question.question.findOne', roleType: 'authenticated' },
- { action: 'api::question-category.question-category.find', roleType: 'authenticated' },
- { action: 'api::question-category.question-category.findOne', roleType: 'authenticated' },
- { action: 'api::question-type.question-type.find', roleType: 'authenticated' },
- { action: 'api::question-type.question-type.findOne', roleType: 'authenticated' }
+ { action: 'api::alliance.alliance.find', roleTypes: ['authenticated'] },
+ { action: 'api::alliance.alliance.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::candidate.answers.overwrite', roleTypes: ['authenticated'] },
+ { action: 'api::candidate.answers.update', roleTypes: ['authenticated'] },
+ { action: 'api::candidate.candidate.find', roleTypes: ['authenticated'] },
+ { action: 'api::candidate.candidate.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::candidate.candidate.update', roleTypes: ['authenticated'] },
+ { action: 'api::candidate.properties.update', roleTypes: ['authenticated'] },
+ { action: 'api::constituency-group.constituency-group.find', roleTypes: ['authenticated'] },
+ { action: 'api::constituency-group.constituency-group.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::constituency.constituency.find', roleTypes: ['authenticated'] },
+ { action: 'api::constituency.constituency.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::election.election.find', roleTypes: ['authenticated'] },
+ { action: 'api::election.election.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::nomination.nomination.find', roleTypes: ['authenticated'] },
+ { action: 'api::nomination.nomination.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::party.party.find', roleTypes: ['authenticated'] },
+ { action: 'api::party.party.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::question-category.question-category.find', roleTypes: ['authenticated'] },
+ { action: 'api::question-category.question-category.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::question-type.question-type.find', roleTypes: ['authenticated'] },
+ { action: 'api::question-type.question-type.findOne', roleTypes: ['authenticated'] },
+ { action: 'api::question.question.find', roleTypes: ['authenticated'] },
+ { action: 'api::question.question.findOne', roleTypes: ['authenticated'] },
+ { action: 'plugin::upload.content-api.destroy', roleTypes: ['authenticated'] },
+ { action: 'plugin::upload.content-api.upload', roleTypes: ['authenticated'] },
+ { action: 'plugin::users-permissions.candidate.check', roleTypes: ['public'] },
+ { action: 'plugin::users-permissions.candidate.register', roleTypes: ['public'] },
+ { action: 'plugin::users-permissions.role.find', roleTypes: ['authenticated', 'admin'] },
+ { action: 'plugin::users-permissions.role.findOne', roleTypes: ['authenticated', 'admin'] },
+ { action: 'plugin::users-permissions.user.me', roleTypes: ['authenticated', 'admin'] }
];
module.exports = async (plugin: Core.Plugin) => {
@@ -63,32 +66,47 @@ module.exports = async (plugin: Core.Plugin) => {
advanced.email_reset_password = url;
await pluginStore.set({ key: 'advanced', value: advanced });
- // Setup default permissions
- for (const permission of defaultPermissions) {
- const role = await strapi.query('plugin::users-permissions.role').findOne({
- where: {
- type: permission.roleType
- }
- });
- if (!role) {
- console.error(`Failed to initialize default permissions due to missing role type: ${permission.roleType}`);
- continue;
- }
+ const adminType = await strapi.query('plugin::users-permissions.role').findOne({ where: { type: 'admin' } });
- const count = await strapi.query('plugin::users-permissions.permission').count({
- where: {
- action: permission.action,
- role: role.id
+ if (!adminType) {
+ // Create admin role for admin-ui functions
+ await strapi.query('plugin::users-permissions.role').create({
+ data: {
+ name: 'Admin',
+ description: 'Role for admins who can access the frontend Admin App.',
+ type: 'admin'
}
});
- if (count !== 0) continue;
+ }
- await strapi.query('plugin::users-permissions.permission').create({
- data: {
- action: permission.action,
- role: role.id
+ // Setup default permissions
+ for (const permission of defaultPermissions) {
+ for (const roleType of permission.roleTypes) {
+ const role = await strapi.query('plugin::users-permissions.role').findOne({
+ where: {
+ type: roleType
+ }
+ });
+ if (!role) {
+ console.error(`Failed to initialize default permissions due to missing role type: ${roleType}`);
+ continue;
}
- });
+
+ const count = await strapi.query('plugin::users-permissions.permission').count({
+ where: {
+ action: permission.action,
+ role: role.id
+ }
+ });
+ if (count !== 0) continue;
+
+ await strapi.query('plugin::users-permissions.permission').create({
+ data: {
+ action: permission.action,
+ role: role.id
+ }
+ });
+ }
}
// Setup email template (the default template also does not make the URL clickable)
diff --git a/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts b/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts
index 153e08cd5..de95d04a8 100644
--- a/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts
+++ b/backend/vaa-strapi/src/functions/setDefaultApiPermissions.ts
@@ -1,9 +1,24 @@
import { PUBLIC_API } from '../util/api';
+import type { StrapiRole } from '../../types/customStrapiTypes';
-export async function setDefaultApiPermissions() {
+export async function setDefaultApiPermissions(roleType: StrapiRole['type']) {
console.info('[setDefaultApiPermissions] Setting default API permissions');
- const roleId = 2; // Role for public user
+ let roleId: number;
+ switch (roleType) {
+ case 'authenticated': {
+ roleId = 1;
+ break;
+ }
+ case 'public': {
+ roleId = 2;
+ break;
+ }
+ case 'admin': {
+ roleId = 3;
+ break;
+ }
+ }
// Voter App
for (const contentType of Object.values(PUBLIC_API)) {
diff --git a/backend/vaa-strapi/src/index.ts b/backend/vaa-strapi/src/index.ts
index fa02a8a2e..0793cb4b9 100644
--- a/backend/vaa-strapi/src/index.ts
+++ b/backend/vaa-strapi/src/index.ts
@@ -43,6 +43,8 @@ module.exports = {
}
// 2. Default API permissions
- setDefaultApiPermissions();
+ setDefaultApiPermissions('public');
+ setDefaultApiPermissions('authenticated');
+ setDefaultApiPermissions('admin');
}
};
diff --git a/backend/vaa-strapi/src/policies/user-is-admin.ts b/backend/vaa-strapi/src/policies/user-is-admin.ts
new file mode 100644
index 000000000..9e9e31d7c
--- /dev/null
+++ b/backend/vaa-strapi/src/policies/user-is-admin.ts
@@ -0,0 +1,16 @@
+import { warn } from '../util/logger';
+import type { StrapiContext } from '../../types/customStrapiTypes';
+
+/**
+ * Policy that only allows admin users to use LLM functions.
+ * NB! Admin role is different from Strapi admin role
+ */
+export default function isAdmin(ctx: StrapiContext): boolean {
+ const userIsAuthenticated = ctx.state?.isAuthenticated;
+ const role = ctx.state?.user?.role?.type;
+ if (role === 'admin' && userIsAuthenticated) {
+ return true;
+ }
+ warn('[global::user-is-admin] triggered by:', ctx.request);
+ return false;
+}
diff --git a/backend/vaa-strapi/types/customStrapiTypes.d.ts b/backend/vaa-strapi/types/customStrapiTypes.d.ts
index 29845ec12..036c5eda5 100644
--- a/backend/vaa-strapi/types/customStrapiTypes.d.ts
+++ b/backend/vaa-strapi/types/customStrapiTypes.d.ts
@@ -9,7 +9,7 @@ export type StrapiContext = RequestContext & {
query: StrapiQuery | Record>;
url: string;
};
- state?: { user: { id: number } };
+ state?: { user: { id: number; role: StrapiRole } };
};
/**
@@ -26,4 +26,15 @@ export type StrapiQuery = {
locale: string | Array;
};
+/**
+ * A non-exhaustive type for a Strapi Role.
+ * See: https://docs.strapi.io/dev-docs/backend-customization/requests-responses#ctxstateuser
+ */
+export type StrapiRole = {
+ id: number;
+ documentId: string;
+ description: string;
+ type: 'authenticated' | 'public' | 'admin';
+};
+
type RequestContext = ReturnType;
diff --git a/frontend/src/lib/api/adapters/strapi/dataWriter/strapiDataWriter.ts b/frontend/src/lib/api/adapters/strapi/dataWriter/strapiDataWriter.ts
index 8fc0679c3..2bd54278f 100644
--- a/frontend/src/lib/api/adapters/strapi/dataWriter/strapiDataWriter.ts
+++ b/frontend/src/lib/api/adapters/strapi/dataWriter/strapiDataWriter.ts
@@ -96,7 +96,16 @@ export class StrapiDataWriter extends strapiAdapterMixin(UniversalDataWriter) {
}
protected async _getBasicUserData({ authToken }: WithAuth): DWReturnType {
- const data = await this.apiGet({ endpoint: 'basicUserData', authToken, disableCache: true });
+ const data = await this.apiGet({
+ endpoint: 'basicUserData',
+ authToken,
+ disableCache: true,
+ params: {
+ populate: {
+ role: 'true'
+ }
+ }
+ });
if (!data) throw new Error('Expected one BasicUserData object, but got none.');
return parseUser(data);
}
@@ -156,7 +165,8 @@ export class StrapiDataWriter extends strapiAdapterMixin(UniversalDataWriter) {
nominations: loadNominations ? { populate: '*' } : 'false',
image: 'true'
}
- }
+ },
+ role: 'true'
}
};
const data = await this.apiGet({
diff --git a/frontend/src/lib/api/adapters/strapi/strapiData.type.ts b/frontend/src/lib/api/adapters/strapi/strapiData.type.ts
index 3ac199ba0..17a515170 100644
--- a/frontend/src/lib/api/adapters/strapi/strapiData.type.ts
+++ b/frontend/src/lib/api/adapters/strapi/strapiData.type.ts
@@ -258,8 +258,17 @@ export type StrapiUserProperties = {
email: string;
confirmed: boolean;
blocked: boolean;
+ role?: StrapiSingleRelation;
};
+export type StrapiRoleData = StrapiObject<{
+ name: StrapiRoleName;
+ description: string;
+ type: StrapiRoleName;
+}>;
+
+export type StrapiRoleName = 'authenticated' | 'public' | 'admin';
+
export type StrapiBasicUserData = StrapiObject;
export type StrapiCandidateUserData = StrapiObject<
diff --git a/frontend/src/lib/api/adapters/strapi/utils/parseUser.ts b/frontend/src/lib/api/adapters/strapi/utils/parseUser.ts
index 36499e105..57b791c91 100644
--- a/frontend/src/lib/api/adapters/strapi/utils/parseUser.ts
+++ b/frontend/src/lib/api/adapters/strapi/utils/parseUser.ts
@@ -1,11 +1,17 @@
import { formatId } from '$lib/api/utils/formatId';
-import type { BasicUserData } from '$lib/api/base/dataWriter.type';
-import type { StrapiBasicUserData } from '../strapiData.type';
+import type { BasicUserData, UserRole } from '$lib/api/base/dataWriter.type';
+import type { StrapiBasicUserData, StrapiRoleName } from '../strapiData.type';
+
+export const STRAPI_ROLES: Record = {
+ admin: 'admin',
+ authenticated: 'candidate',
+ public: null
+};
/**
* Parse a Strapi User data `BasicUserData` object.
*/
-export function parseUser({ documentId, username, email, confirmed, blocked }: StrapiBasicUserData): BasicUserData {
+export function parseUser({ documentId, username, email, role }: StrapiBasicUserData): BasicUserData {
const id = formatId(documentId);
return {
id,
@@ -15,7 +21,6 @@ export function parseUser({ documentId, username, email, confirmed, blocked }: S
},
username,
email,
- confirmed,
- blocked
+ role: role ? STRAPI_ROLES[role.type] : null
};
}
diff --git a/frontend/src/lib/api/base/actionResult.type.ts b/frontend/src/lib/api/base/actionResult.type.ts
index 998311a5d..674f779f4 100644
--- a/frontend/src/lib/api/base/actionResult.type.ts
+++ b/frontend/src/lib/api/base/actionResult.type.ts
@@ -1,6 +1,7 @@
/**
- * The format for the data returned by actions which either succeed or fail but return no other data.
+ * The format for the data returned by actions which either succeed or fail.
*/
export interface DataApiActionResult extends Record {
type: 'failure' | 'success';
+ status?: number;
}
diff --git a/frontend/src/lib/api/base/dataWriter.type.ts b/frontend/src/lib/api/base/dataWriter.type.ts
index 9eff7e889..9670c69a0 100644
--- a/frontend/src/lib/api/base/dataWriter.type.ts
+++ b/frontend/src/lib/api/base/dataWriter.type.ts
@@ -98,10 +98,16 @@ export interface DataWriter {
*/
login: (opts: { username: string; password: string }) => DWReturnType, TType>;
/**
- * Logout a user.
+ * Logout a user from both the frontend and the backend.
* @returns A `Promise` resolving to an `DataApiActionResult` object or a `Response` containing one.
*/
logout: (opts: WithAuth) => DWReturnType;
+ /**
+ * Logout a user from the backend only.
+ * This is mostly used by the login server api route to undo a login attempt.
+ * @returns A `Promise` resolving to an `DataApiActionResult` object or a `Response` containing one.
+ */
+ backendLogout: (opts: WithAuth) => DWReturnType;
/**
* Get the basic data for a user, mostly their username, email, and preferred language.
* @param authToken - The authorization token.
@@ -212,10 +218,11 @@ export interface BasicUserData extends WithUserSettings {
id: Id;
email: string;
username: string;
- confirmed: boolean;
- blocked: boolean;
+ role?: UserRole | null;
}
+export type UserRole = 'candidate' | 'admin';
+
export type CheckRegistrationData = DataApiActionResult & {
email: string;
firstName: string;
diff --git a/frontend/src/lib/api/base/universalApiRoutes.ts b/frontend/src/lib/api/base/universalApiRoutes.ts
index 9057f6cf3..3fc1a04d1 100644
--- a/frontend/src/lib/api/base/universalApiRoutes.ts
+++ b/frontend/src/lib/api/base/universalApiRoutes.ts
@@ -8,8 +8,9 @@ export const API_ROOT = '/api';
* Api routes that are used by the universal adapters.
*/
export const UNIVERSAL_API_ROUTES = {
+ cacheProxy: `${API_ROOT}/auth/cache`,
+ login: `${API_ROOT}/auth/login`,
logout: `${API_ROOT}/candidate/logout`,
preregister: `${API_ROOT}/candidate/preregister`,
- token: `${API_ROOT}/oidc/token`,
- cacheProxy: `${API_ROOT}/cache`
+ token: `${API_ROOT}/oidc/token`
} as const;
diff --git a/frontend/src/lib/api/base/universalDataWriter.ts b/frontend/src/lib/api/base/universalDataWriter.ts
index 55c74553a..cc438c640 100644
--- a/frontend/src/lib/api/base/universalDataWriter.ts
+++ b/frontend/src/lib/api/base/universalDataWriter.ts
@@ -18,7 +18,7 @@ import type {
/**
* The abstract base class that all universal `DataWriter`s should extend.
*
- * The subclasses must implement the protected `_foo` methods paired with each public `Foo` method. The implementations may freely throw errors.
+ * The subclasses must implement the protected methods. The implementations may freely throw errors.
*/
export abstract class UniversalDataWriter extends UniversalAdapter implements DataWriter {
////////////////////////////////////////////////////////////////////
@@ -105,7 +105,7 @@ export abstract class UniversalDataWriter extends UniversalAdapter implements Da
}
}
})) as DataApiActionResult,
- this._logout(opts)
+ this.backendLogout(opts)
]);
if (clientResult.type === 'success' && backendResult.type === 'success') return backendResult;
else
@@ -117,6 +117,10 @@ export abstract class UniversalDataWriter extends UniversalAdapter implements Da
};
}
+ async backendLogout(opts: WithAuth): DWReturnType {
+ return this._logout(opts);
+ }
+
getBasicUserData(opts: WithAuth): DWReturnType {
return this._getBasicUserData(opts);
}
diff --git a/frontend/src/lib/api/utils/fail.ts b/frontend/src/lib/api/utils/fail.ts
new file mode 100644
index 000000000..79738821c
--- /dev/null
+++ b/frontend/src/lib/api/utils/fail.ts
@@ -0,0 +1,10 @@
+import { json } from '@sveltejs/kit';
+import type { DataApiActionResult } from '$lib/api/base/actionResult.type';
+
+/**
+ * Return a failure json response.
+ * @param status - The HTTP status code for the response.
+ */
+export function apiFail(status = 500): Response {
+ return json({ ok: false, type: 'failure', status } as DataApiActionResult);
+}
diff --git a/frontend/src/lib/candidate/utils/loginError.ts b/frontend/src/lib/candidate/utils/loginError.ts
index dbad3d069..c9507bcec 100644
--- a/frontend/src/lib/candidate/utils/loginError.ts
+++ b/frontend/src/lib/candidate/utils/loginError.ts
@@ -12,7 +12,8 @@ const CANDIDATE_LOGIN_ERROR: Record = {
candidateNoNomination: 'candidateApp.error.candidateNoNomination',
loginFailed: 'candidateApp.error.loginFailed',
nominationNoElection: 'candidateApp.error.nominationNoElection',
- userNoCandidate: 'candidateApp.error.userNoCandidate'
+ userNoCandidate: 'candidateApp.error.userNoCandidate',
+ userNotAuthorized: 'error.403'
} as const;
/**
diff --git a/frontend/src/lib/i18n/translations/en/error.json b/frontend/src/lib/i18n/translations/en/error.json
index 168759359..844802304 100644
--- a/frontend/src/lib/i18n/translations/en/error.json
+++ b/frontend/src/lib/i18n/translations/en/error.json
@@ -1,4 +1,5 @@
{
+ "403": "You’re unfortunately not authorized to access this content.",
"404": "Page not found, sorry!",
"500": "Internal server error, sorry!",
"content": "
We're very sorry for the inconvenience. You can try to go back and reload the page and see if the problem persists.
If you still experience the problem, please send an email to {adminEmailLink} and describe the problem. Thank you!
",
diff --git a/frontend/src/lib/i18n/translations/fi/error.json b/frontend/src/lib/i18n/translations/fi/error.json
index a6a00a243..54815e1d7 100644
--- a/frontend/src/lib/i18n/translations/fi/error.json
+++ b/frontend/src/lib/i18n/translations/fi/error.json
@@ -1,4 +1,5 @@
{
+ "403": "Sinulla ei valitettavasti ole käyttöoikeuksia tähän sisältöön.",
"404": "Sivua ei löytynyt, anteeksi!",
"500": "Palvelinvirhe, anteeksi!",
"content": "
Olemme todella pahoillamme harmista. Voit koettaa palata takaisin ja ladata sivun uudelleen ja katsoa, toistuuko ongelma.
Jos tilanne ei korjaannu, pyydämme ystävällisesti lähettämään sähköpostia osoitteeseen {adminEmailLink} ja kuvailemaan mahdollisimman tarkasti, mitä tapahtui. Kiitos etukäteen!
",
diff --git a/frontend/src/lib/i18n/translations/sv/error.json b/frontend/src/lib/i18n/translations/sv/error.json
index 11511b473..e8bd3f5da 100644
--- a/frontend/src/lib/i18n/translations/sv/error.json
+++ b/frontend/src/lib/i18n/translations/sv/error.json
@@ -1,4 +1,5 @@
{
+ "403": "Tyvärr har du inte behörighet att komma åt detta innehåll.",
"404": "Sidan hittades inte, tyvärr!",
"500": "Internt serverfel, tyvärr!",
"content": "
Vi är mycket ledsna för besväret. Du kan försöka gå tillbaka och ladda om sidan och se om problemet kvarstår.
Om du fortfarande upplever problemet, vänligen skicka ett e-postmeddelande till {adminEmailLink} och beskriv problemet. Tack så mycket!
",
diff --git a/frontend/src/lib/types/generated/translationKey.ts b/frontend/src/lib/types/generated/translationKey.ts
index f657a9b51..efa4c20f1 100644
--- a/frontend/src/lib/types/generated/translationKey.ts
+++ b/frontend/src/lib/types/generated/translationKey.ts
@@ -381,6 +381,7 @@ export type TranslationKey =
| 'entityList.controls.searchPlaceholder'
| 'entityList.controls.showingNumResults'
| 'entityList.showMore'
+ | 'error.403'
| 'error.404'
| 'error.500'
| 'error.content'
diff --git a/frontend/src/routes/[[lang=locale]]/api/auth/login/+server.ts b/frontend/src/routes/[[lang=locale]]/api/auth/login/+server.ts
new file mode 100644
index 000000000..0d1fbc21a
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/api/auth/login/+server.ts
@@ -0,0 +1,81 @@
+import { json } from '@sveltejs/kit';
+import { dataWriter as dataWriterPromise } from '$lib/api/dataWriter';
+import { apiFail } from '$lib/api/utils/fail';
+import { logDebugError } from '$lib/utils/logger';
+import type { DataApiActionResult } from '$lib/api/base/actionResult.type';
+import type { BasicUserData, UserRole } from '$lib/api/base/dataWriter.type';
+
+/**
+ * # Login api route. Call this from page actions.
+ *
+ * On succesfull login saves the jwt token into the cookie.
+ *
+ * @params params - `LoginParams`
+ * @returns A json `Response` with a `DataApiActionResult` and `BasicUserData`.
+ */
+
+export async function POST({ cookies, request, locals, fetch }) {
+ const dataWriter = await dataWriterPromise;
+ dataWriter.init({ fetch });
+
+ const { username, password, role } = (await request.json()) as LoginParams;
+
+ const loginResponse = await dataWriter.login({ username, password }).catch(() => undefined);
+ if (!loginResponse?.authToken) return apiFail(400);
+ const { authToken } = loginResponse;
+
+ const userData = await dataWriter.getBasicUserData({ authToken }).catch((e) => {
+ logDebugError(`Error fetching user data: ${e?.message ?? 'No error message'}`);
+ return undefined;
+ });
+
+ if (!userData) return apiFail(500);
+
+ if (role != null && ![role].flat().includes(userData.role!)) {
+ await dataWriter
+ .backendLogout({ authToken })
+ .catch((e) =>
+ logDebugError(`Error handling backendLogout for unauthorized user: ${e?.message ?? 'No error message'}`)
+ );
+ console.error('Unauthorized user tried to access restricted resource');
+ return apiFail(403);
+ }
+
+ // Only set the auth token if we also got the basic user data and the role matched
+ cookies.set('token', authToken, {
+ httpOnly: true,
+ secure: true,
+ sameSite: 'strict',
+ path: '/'
+ });
+
+ const language = userData.settings?.language;
+ if (language) {
+ locals.currentLocale = language;
+ }
+
+ return json({ ok: true, type: 'success', userData } as LoginResult);
+}
+
+/**
+ * The parameters for the login api route.
+ */
+export type LoginParams = {
+ /**
+ * The user's email.
+ */
+ username: string;
+ /**
+ * The user's password.
+ */
+ password: string;
+ /**
+ * Optional role or array of roles in the `BasicUserData`. If set, a 403 error will be returned if the role does not match, and the logout sequence will be initiated.
+ */
+ role?: UserRole | Array;
+};
+
+/**
+ * Returned on successful login.
+ */
+export type LoginResult = DataApiActionResult & { userData: BasicUserData };
diff --git a/frontend/src/routes/[[lang=locale]]/api/candidate/logout/+server.ts b/frontend/src/routes/[[lang=locale]]/api/auth/logout/+server.ts
similarity index 86%
rename from frontend/src/routes/[[lang=locale]]/api/candidate/logout/+server.ts
rename to frontend/src/routes/[[lang=locale]]/api/auth/logout/+server.ts
index eebcafab3..ddb429475 100644
--- a/frontend/src/routes/[[lang=locale]]/api/candidate/logout/+server.ts
+++ b/frontend/src/routes/[[lang=locale]]/api/auth/logout/+server.ts
@@ -3,6 +3,8 @@ import type { DataApiActionResult } from '$lib/api/base/actionResult.type';
/**
* An API route for logging out candidates.
+ *
+ * @returns A json `Response` with a `DataApiActionResult`.
*/
export async function POST({ cookies }) {
cookies.delete('token', {
diff --git a/frontend/src/routes/[[lang=locale]]/candidate/(protected)/+layout.ts b/frontend/src/routes/[[lang=locale]]/candidate/(protected)/+layout.ts
index 1aad0fc87..8cc8783ab 100644
--- a/frontend/src/routes/[[lang=locale]]/candidate/(protected)/+layout.ts
+++ b/frontend/src/routes/[[lang=locale]]/candidate/(protected)/+layout.ts
@@ -38,11 +38,13 @@ export async function load({ fetch, parent, params: { lang } }) {
});
if (!userData) return await handleError('loginFailed');
- // Check that the data is valid
+ // Check that the data is valid and the user is a candidate
const {
+ user: { role },
candidate,
nominations: { nominations }
} = userData;
+ if (role !== 'candidate') return await handleError('userNotAuthorized');
if (!candidate) return await handleError('userNoCandidate');
// Parse the election and constituency ids
diff --git a/frontend/src/routes/[[lang=locale]]/candidate/login/+page.server.ts b/frontend/src/routes/[[lang=locale]]/candidate/login/+page.server.ts
index 294458e71..05a00290a 100644
--- a/frontend/src/routes/[[lang=locale]]/candidate/login/+page.server.ts
+++ b/frontend/src/routes/[[lang=locale]]/candidate/login/+page.server.ts
@@ -1,48 +1,32 @@
/**
* # Candidate App login server action
- *
- * On succesfull login saves the jwt token into the cookie.
*/
import { fail, redirect } from '@sveltejs/kit';
-import { dataWriter as dataWriterPromise } from '$lib/api/dataWriter';
-import { logDebugError } from '$lib/utils/logger';
+import { UNIVERSAL_API_ROUTES } from '$lib/api/base/universalApiRoutes.js';
import { buildRoute } from '$lib/utils/route';
+import type { LoginParams, LoginResult } from '../../api/auth/login/+server';
export const actions = {
- default: async ({ cookies, request, locals, fetch }) => {
- const dataWriter = await dataWriterPromise;
- dataWriter.init({ fetch });
-
+ default: async ({ request, fetch, locals }) => {
const data = await request.formData();
const username = data.get('email') as string;
const password = data.get('password') as string;
const redirectTo = data.get('redirectTo') as string;
- const loginResponse = await dataWriter.login({ username, password }).catch(() => undefined);
- if (!loginResponse?.authToken) return fail(400);
- const { authToken } = loginResponse;
-
- const userData = await dataWriter.getBasicUserData({ authToken }).catch((e) => {
- logDebugError(`Error fetching user data: ${e?.message ?? 'No error message'}`);
- return undefined;
+ const response = await fetch(UNIVERSAL_API_ROUTES.login, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ username, password, role: 'candidate' } as LoginParams)
});
- if (!userData) return fail(500);
- // Only set the auth token if we also got the basic user data
- cookies.set('token', authToken, {
- httpOnly: true,
- secure: true,
- sameSite: 'strict',
- path: '/'
- });
+ const result = (await response.json()) as LoginResult;
- const language = userData.settings?.language;
- if (language) {
- locals.currentLocale = language;
- }
+ if (result.type !== 'success') return fail(result.status ?? 500);
- redirect(
+ return redirect(
303,
redirectTo
? `/${locals.currentLocale}/${redirectTo}`
diff --git a/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte b/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte
index d3926e7bc..05bd0ae56 100644
--- a/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte
+++ b/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte
@@ -133,7 +133,9 @@
errorMessage =
result.status === 400
? $t('candidateApp.login.wrongEmailOrPassword')
- : $t('candidateApp.login.unknownError');
+ : result.status === 403
+ ? $t('error.403')
+ : $t('candidateApp.login.unknownError');
return;
}
await applyAction(result);
From 02e2c6e5b58556e937666bd457d6de164d69b98a Mon Sep 17 00:00:00 2001
From: kallelongjuhani
Date: Tue, 12 Aug 2025 16:22:22 +0300
Subject: [PATCH 003/136] feat: `AuthContext`
Move content related to logging in and out, and editing the password to
a new context used by both the Candidate and upcoming Admin Apps.
---
docs/frontend.md | 3 +
frontend/src/lib/contexts/auth/authContext.ts | 74 ++++
.../src/lib/contexts/auth/authContext.type.ts | 40 ++
frontend/src/lib/contexts/auth/index.ts | 2 +
.../contexts/candidate/candidateContext.ts | 40 +-
.../candidate/candidateContext.type.ts | 348 +++++++++---------
...DataStore.ts => candidateUserDataStore.ts} | 13 +-
...type.ts => candidateUserDataStore.type.ts} | 2 +-
frontend/src/lib/contexts/candidate/index.ts | 2 +-
.../{candidate => utils}/prepareDataWriter.ts | 2 +-
.../src/routes/[[lang=locale]]/+layout.svelte | 3 +
11 files changed, 306 insertions(+), 223 deletions(-)
create mode 100644 frontend/src/lib/contexts/auth/authContext.ts
create mode 100644 frontend/src/lib/contexts/auth/authContext.type.ts
create mode 100644 frontend/src/lib/contexts/auth/index.ts
rename frontend/src/lib/contexts/candidate/{userDataStore.ts => candidateUserDataStore.ts} (95%)
rename frontend/src/lib/contexts/candidate/{userDataStore.type.ts => candidateUserDataStore.type.ts} (97%)
rename frontend/src/lib/contexts/{candidate => utils}/prepareDataWriter.ts (85%)
diff --git a/docs/frontend.md b/docs/frontend.md
index 5dad890b6..1ffec2aba 100644
--- a/docs/frontend.md
+++ b/docs/frontend.md
@@ -72,9 +72,12 @@ The properties accessed are:
| [`ComponentContext`](/frontend/src/lib/contexts/component/componentContext.type.ts) | Functions available to all components | Any component | `I18n` | — `darkMode: Readable` | `/[lang]` |
| [`DataContext`](/frontend/src/lib/contexts/data/dataContext.type.ts) | All VAA data (using the `@openvaa/data` model) | Other contexts | `I18n`\* | — `dataRoot: Readable` | `/[lang]` |
| [`AppContext`](/frontend/src/lib/contexts/app/appContext.type.ts) | All functions shared by the Voter and Candidate Apps | Any page, layout or dynamic component | `I18n`, `VaaData`, `Component` | — `appType: Writable` — `appSettings: SettingsStore` — `userPreferences: Writable` — `getRoute: Readable<(options: RouteOptions) => string>` — `sendFeedback: (data: FeedbackData) => Promise` — contents of `TrackinService` — popup and modal dialog handling — handling data consent and user surveys | `/[lang]` |
+| ` |
+| [`AuthContext`](/frontend/src/lib/contexts/auth/authContext.type.ts) | Functions for logging in and out as well password editing | Any page or layout | — | – `authToken` — `logout()` — `requestForgotPasswordEmail(opts)` — `resetPassword(opts)` — `setPassword(opts)` | `/[lang]` |
| [`LayoutContext`](/frontend/src/lib/contexts/layout/layoutContext.type.ts) | Functions for subpages to affect the outer application layout | Any page or layout | — | — `topBarSettings: StackedStore>` — `pageStyles: StackedStore>` — `progress: Progress` — `navigation: Navigation` | `/[lang]` |
| [`VoterContext`](/frontend/src/lib/contexts/voter/voterContext.type.ts) | All functions exclusive to the Voter App | Any part of the Voter App or dynamic components (conditionally) | `App` | — `answers: AnswerStore` — `matches: Readable` — `entityFilters: Readable` — `infoQuestions: Readable>` — `opinionQuestions: Readable>` — `selectedConstituencies: Readable>` — `selectedElections: Readable>` — handling question category selection and question ordering — other selection-related functions | `/[lang]/(voter)` |
| [`CandidateContext`](/frontend/src/lib/contexts/candidate/candidateContext.type.ts) | All functions exclusive to the Candidate App | Any part of the Candidate App or dynamic components (conditionally) | `App` | — `userData: UserDataStore` — `preregistrationElectionIds: Readable>` and other preregisration stores — `selectedElections: Readable>` and other stores matching those in `VoterContext` — `checkRegistrationKey(opts)` — `register(opts)` — `logout()` — `requestForgotPasswordEmail(opts)` — `resetPassword(opts)` — `setPassword(opts)` — `exchangeCodeForIdToken(opts)` — `preregister(opts)` — `clearIdToken()` — `answersLocked: Readable` — `requiredInfoQuestions: Readable>` and other utility derived stores | `/[lang]/candidate` |
+| [`AdminContext`](/frontend/src/lib/contexts/admin/adminContext.type.ts) | All functions exclusive to the Admin App | Any part of the Admin App or dynamic components (conditionally) | `App`, `Auth` | TBA | `/[lang]/admin` |
\* The `DataContext` accesses the `I18nContext` because it needs `locale` but it doesn’t export its contents.
diff --git a/frontend/src/lib/contexts/auth/authContext.ts b/frontend/src/lib/contexts/auth/authContext.ts
new file mode 100644
index 000000000..b186b51d0
--- /dev/null
+++ b/frontend/src/lib/contexts/auth/authContext.ts
@@ -0,0 +1,74 @@
+import { error } from '@sveltejs/kit';
+import { getContext, hasContext, setContext } from 'svelte';
+import { derived, get } from 'svelte/store';
+import { page } from '$app/stores';
+import { dataWriter as dataWriterPromise } from '$lib/api/dataWriter';
+import { logDebugError } from '$lib/utils/logger';
+import { prepareDataWriter } from '../utils/prepareDataWriter';
+import type { DataApiActionResult } from '$lib/api/base/actionResult.type';
+import type { DataWriter } from '$lib/api/base/dataWriter.type';
+import type { AuthContext } from './authContext.type';
+
+const CONTEXT_KEY = Symbol();
+
+export function getAuthContext(): AuthContext {
+ if (!hasContext(CONTEXT_KEY)) error(500, 'getAuthContext() called before initAuthContext()');
+ return getContext(CONTEXT_KEY);
+}
+
+/**
+ * Initialize and return the context. This must be called before `getAuthContext()` and cannot be called twice.
+ * @returns The context object
+ */
+export function initAuthContext(): AuthContext {
+ if (hasContext(CONTEXT_KEY)) error(500, 'initAuthContext() called for a second time');
+
+ const authToken = derived(page, (page) => page.data.token ?? undefined);
+
+ ////////////////////////////////////////////////////////////////////
+ // Wrappers for DataWriter methods
+ // NB. These automatically handle authentication
+ ////////////////////////////////////////////////////////////////////
+
+ async function requestForgotPasswordEmail(
+ ...args: Parameters
+ ): ReturnType {
+ const dw = await prepareDataWriter(dataWriterPromise);
+ return dw.requestForgotPasswordEmail(...args);
+ }
+
+ async function resetPassword(
+ ...args: Parameters
+ ): ReturnType {
+ const dw = await prepareDataWriter(dataWriterPromise);
+ return dw.resetPassword(...args);
+ }
+
+ async function logout(): Promise {
+ const token = get(authToken);
+ if (!token) throw new Error('No authentication token');
+ const dataWriter = await prepareDataWriter(dataWriterPromise);
+ await dataWriter.logout({ authToken: token }).catch((e) => {
+ logDebugError(`Error logging out: ${e?.message ?? '-'}`);
+ });
+ }
+
+ async function setPassword(opts: { currentPassword: string; password: string }): Promise {
+ const token = get(authToken);
+ if (!token) throw new Error('No authentication token');
+ const dataWriter = await prepareDataWriter(dataWriterPromise);
+ return dataWriter.setPassword({ ...opts, authToken: token });
+ }
+
+ ////////////////////////////////////////////////////////////
+ // Build context
+ ////////////////////////////////////////////////////////////
+
+ return setContext(CONTEXT_KEY, {
+ authToken,
+ logout,
+ requestForgotPasswordEmail,
+ resetPassword,
+ setPassword
+ });
+}
diff --git a/frontend/src/lib/contexts/auth/authContext.type.ts b/frontend/src/lib/contexts/auth/authContext.type.ts
new file mode 100644
index 000000000..c6c140903
--- /dev/null
+++ b/frontend/src/lib/contexts/auth/authContext.type.ts
@@ -0,0 +1,40 @@
+import type { Readable } from 'svelte/store';
+import type { DataWriter } from '$lib/api/base/dataWriter.type';
+
+export type AuthContext = {
+ /**
+ * Holds the jwt token. NB. The context’s internal methods use it automatically for authentication.
+ */
+ authToken: Readable;
+
+ ////////////////////////////////////////////////////////////////////
+ // Wrappers for DataWriter methods
+ // NB. These automatically handle authentication
+ ////////////////////////////////////////////////////////////////////
+
+ /**
+ * Logout the user and redirect to the login page.
+ * @returns A `Promise` resolving when the redirection is complete.
+ */
+ logout: () => Promise;
+ /**
+ * Request that the a password reset email sent to the user.
+ * @param email - The user’s email.
+ * @returns A `Promise` resolving to an `DataApiActionResult` object.
+ */
+ requestForgotPasswordEmail: (opts: { email: string }) => ReturnType;
+ /**
+ * Check whether the registration key is valid.
+ * @param code - The password reset code.
+ * @param password - The new password.
+ * @returns A `Promise` resolving to an `DataApiActionResult` object.
+ */
+ resetPassword: (opts: { code: string; password: string }) => ReturnType;
+ /**
+ * Change a user’s password.
+ * @param currentPassword - The current password.
+ * @param password - The new password.
+ * @returns A `Promise` resolving to an `DataApiActionResult` object.
+ */
+ setPassword: (opts: { currentPassword: string; password: string }) => ReturnType;
+};
diff --git a/frontend/src/lib/contexts/auth/index.ts b/frontend/src/lib/contexts/auth/index.ts
new file mode 100644
index 000000000..dcb2b43f2
--- /dev/null
+++ b/frontend/src/lib/contexts/auth/index.ts
@@ -0,0 +1,2 @@
+export * from './authContext';
+export * from './authContext.type';
diff --git a/frontend/src/lib/contexts/candidate/candidateContext.ts b/frontend/src/lib/contexts/candidate/candidateContext.ts
index 845a798b7..064acdf2d 100644
--- a/frontend/src/lib/contexts/candidate/candidateContext.ts
+++ b/frontend/src/lib/contexts/candidate/candidateContext.ts
@@ -9,15 +9,15 @@ import { dataWriter as dataWriterPromise } from '$lib/api/dataWriter';
import { logDebugError } from '$lib/utils/logger';
import { removeDuplicates } from '$lib/utils/removeDuplicates';
import { getImpliedElectionIds } from '$lib/utils/route';
-import { prepareDataWriter } from './prepareDataWriter';
-import { userDataStore } from './userDataStore';
+import { candidateUserDataStore } from './candidateUserDataStore';
import { getAppContext } from '../app';
+import { getAuthContext } from '../auth';
+import { prepareDataWriter } from '../utils/prepareDataWriter';
import { questionBlockStore } from '../utils/questionBlockStore';
import { extractInfoCategories, extractOpinionCategories, questionCategoryStore } from '../utils/questionCategoryStore';
import { questionStore } from '../utils/questionStore';
import { localStorageWritable, sessionStorageWritable } from '../utils/storageStore';
import type { Id } from '@openvaa/core';
-import type { DataApiActionResult } from '$lib/api/base/actionResult.type';
import type { DataWriter } from '$lib/api/base/dataWriter.type';
import type { CandidateContext } from './candidateContext.type';
@@ -42,17 +42,18 @@ export function initCandidateContext(): CandidateContext {
const appContext = getAppContext();
const { appSettings, dataRoot, getRoute, locale } = appContext;
+ const authContext = getAuthContext();
+ const { authToken, logout: _logout } = authContext;
+
////////////////////////////////////////////////////////////////////
// User data, authentication and answersLocked
////////////////////////////////////////////////////////////////////
const answersLocked = derived(appSettings, (appSettings) => !!appSettings.access.answersLocked);
- const authToken = derived(page, (page) => page.data.token ?? undefined);
-
const idTokenClaims = derived(page, (page) => page.data.claims ?? undefined);
- const userData = userDataStore({ answersLocked, authToken, dataWriterPromise, locale });
+ const userData = candidateUserDataStore({ answersLocked, authToken, dataWriterPromise, locale });
const newUserEmail = writable();
@@ -164,32 +165,12 @@ export function initCandidateContext(): CandidateContext {
function register(...args: Parameters): ReturnType {
return prepareDataWriter(dataWriterPromise).then((dw) => dw.register(...args));
}
- function requestForgotPasswordEmail(
- ...args: Parameters
- ): ReturnType {
- return prepareDataWriter(dataWriterPromise).then((dw) => dw.requestForgotPasswordEmail(...args));
- }
- function resetPassword(...args: Parameters): ReturnType {
- return prepareDataWriter(dataWriterPromise).then((dw) => dw.resetPassword(...args));
- }
async function logout(): Promise {
- const token = get(authToken);
- if (!token) throw new Error('No authentication token');
- const dataWriter = await prepareDataWriter(dataWriterPromise);
- await dataWriter.logout({ authToken: token }).catch((e) => {
- logDebugError(`Error logging out: ${e?.message ?? '-'}`);
- });
+ await _logout();
return goto(get(getRoute)('CandAppLogin'), { invalidateAll: true }).then(_reset);
}
- async function setPassword(opts: { currentPassword: string; password: string }): Promise {
- const token = get(authToken);
- if (!token) throw new Error('No authentication token');
- const dataWriter = await prepareDataWriter(dataWriterPromise);
- return dataWriter.setPassword({ ...opts, authToken: token });
- }
-
async function exchangeCodeForIdToken(opts: {
authorizationCode: string;
codeVerifier: string;
@@ -308,8 +289,8 @@ export function initCandidateContext(): CandidateContext {
return setContext(CONTEXT_KEY, {
...appContext,
+ ...authContext,
answersLocked,
- authToken,
preregister,
checkRegistrationKey,
constituenciesSelectable,
@@ -326,10 +307,7 @@ export function initCandidateContext(): CandidateContext {
profileComplete,
questionBlocks,
register,
- requestForgotPasswordEmail,
requiredInfoQuestions,
- resetPassword,
- setPassword,
unansweredOpinionQuestions,
unansweredRequiredInfoQuestions,
userData,
diff --git a/frontend/src/lib/contexts/candidate/candidateContext.type.ts b/frontend/src/lib/contexts/candidate/candidateContext.type.ts
index 252ecf0fd..4c46d7fd9 100644
--- a/frontend/src/lib/contexts/candidate/candidateContext.type.ts
+++ b/frontend/src/lib/contexts/candidate/candidateContext.type.ts
@@ -3,195 +3,175 @@ import type { AnyQuestionVariant, Constituency, Election, QuestionCategory } fro
import type { Readable, Writable } from 'svelte/store';
import type { DataWriter } from '$lib/api/base/dataWriter.type';
import type { AppContext } from '../app';
+import type { AuthContext } from '../auth';
import type { QuestionBlocks } from '../utils/questionBlockStore.type';
-import type { UserDataStore } from './userDataStore.type';
+import type { CandidateUserDataStore } from './candidateUserDataStore.type';
-export type CandidateContext = AppContext & {
- ////////////////////////////////////////////////////////////////////
- // Properties matching those in the VoterContext
- ////////////////////////////////////////////////////////////////////
+export type CandidateContext = AppContext &
+ AuthContext & {
+ ////////////////////////////////////////////////////////////////////
+ // Properties matching those in the VoterContext
+ ////////////////////////////////////////////////////////////////////
- /**
- * Whether `Election`s can be selected.
- */
- electionsSelectable: Readable;
- /**
- * Whether `Constituency`s can be selected.
- */
- constituenciesSelectable: Readable;
- /**
- * The `Id`s ... TODO
- */
- preregistrationElectionIds: Readable>;
- /**
- * The `Id`s ... TODO
- */
- preregistrationConstituencyIds: Readable<{ [electionId: Id]: Id }>;
- /**
- * The `Election`s selected or implied in the pregistration process.
- */
- preregistrationElections: Readable>;
- /**
- * The data for the preregistration `Nomination`s derived from selected `Constituency`s and `Election`s.
- */
- preregistrationNominations: Readable<
- Array<{
- electionId: Id;
- constituencyId: Id;
- }>
- >;
- /**
- * The `Election`s the `Candidate` is nominated in.
- */
- selectedElections: Readable>;
- /**
- * The `Constituency`s the `Candidate` is nominated in.
- */
- selectedConstituencies: Readable>;
- /**
- * The non-opinion `QuestionCategory`s applicable to the selected `Election`s and `Constituency`s.
- * NB. When accessing the `Question`s in the categories, use the `getApplicableQuestions({election, constituency})` method.
- */
- infoQuestionCategories: Readable>;
- /**
- * The non-opinion `Question`s applicable to the selected `Election`s and `Constituency`s.
- */
- infoQuestions: Readable>;
- /**
- * The matching `QuestionCategory`s applicable to the selected `Election`s and `Constituency`s.
- * NB. When accessing the `Question`s in the categories, use the `getApplicableQuestions({election, constituency})` method.
- */
- opinionQuestionCategories: Readable>;
- /**
- * The matching `Question`s applicable to the selected `Election`s and `Constituency`s.
- */
- opinionQuestions: Readable>;
- /**
- * The applicable opinion `Question`s applicable to the selected `Election`s and `Constituency`s.
- */
- questionBlocks: Readable;
+ /**
+ * Whether `Election`s can be selected.
+ */
+ electionsSelectable: Readable;
+ /**
+ * Whether `Constituency`s can be selected.
+ */
+ constituenciesSelectable: Readable;
+ /**
+ * The `Id`s ... TODO
+ */
+ preregistrationElectionIds: Readable>;
+ /**
+ * The `Id`s ... TODO
+ */
+ preregistrationConstituencyIds: Readable<{ [electionId: Id]: Id }>;
+ /**
+ * The `Election`s selected or implied in the pregistration process.
+ */
+ preregistrationElections: Readable>;
+ /**
+ * The data for the preregistration `Nomination`s derived from selected `Constituency`s and `Election`s.
+ */
+ preregistrationNominations: Readable<
+ Array<{
+ electionId: Id;
+ constituencyId: Id;
+ }>
+ >;
+ /**
+ * The `Election`s the `Candidate` is nominated in.
+ */
+ selectedElections: Readable>;
+ /**
+ * The `Constituency`s the `Candidate` is nominated in.
+ */
+ selectedConstituencies: Readable>;
+ /**
+ * The non-opinion `QuestionCategory`s applicable to the selected `Election`s and `Constituency`s.
+ * NB. When accessing the `Question`s in the categories, use the `getApplicableQuestions({election, constituency})` method.
+ */
+ infoQuestionCategories: Readable>;
+ /**
+ * The non-opinion `Question`s applicable to the selected `Election`s and `Constituency`s.
+ */
+ infoQuestions: Readable>;
+ /**
+ * The matching `QuestionCategory`s applicable to the selected `Election`s and `Constituency`s.
+ * NB. When accessing the `Question`s in the categories, use the `getApplicableQuestions({election, constituency})` method.
+ */
+ opinionQuestionCategories: Readable>;
+ /**
+ * The matching `Question`s applicable to the selected `Election`s and `Constituency`s.
+ */
+ opinionQuestions: Readable>;
+ /**
+ * The applicable opinion `Question`s applicable to the selected `Election`s and `Constituency`s.
+ */
+ questionBlocks: Readable;
- ////////////////////////////////////////////////////////////////////
- // Wrappers for DataWriter methods
- // NB. These automatically handle authentication
- ////////////////////////////////////////////////////////////////////
+ ////////////////////////////////////////////////////////////////////
+ // Wrappers for DataWriter methods
+ // NB. These automatically handle authentication
+ ////////////////////////////////////////////////////////////////////
- /**
- * Check whether the registration key is valid.
- * @param registrationKey - The registration key to check.
- * @returns A `Promise` resolving to an `DataApiActionResult` object.
- */
- checkRegistrationKey: (opts: { registrationKey: string }) => ReturnType;
- /**
- * Activate an already registered user with the provided registration key and password.
- * @param registrationKey - The registration key to check.
- * @param password - The user’s password.
- * @returns A `Promise` resolving to an `DataApiActionResult` object.
- */
- register: (opts: { registrationKey: string; password: string }) => ReturnType;
- /**
- * Logout the user and redirect to the login page.
- * @returns A `Promise` resolving when the redirection is complete.
- */
- logout: () => Promise;
- /**
- * Request that the a password reset email sent to the user.
- * @param email - The user’s email.
- * @returns A `Promise` resolving to an `DataApiActionResult` object.
- */
- requestForgotPasswordEmail: (opts: { email: string }) => ReturnType;
- /**
- * Check whether the registration key is valid.
- * @param code - The password reset code.
- * @param password - The new password.
- * @returns A `Promise` resolving to an `DataApiActionResult` object.
- */
- resetPassword: (opts: { code: string; password: string }) => ReturnType;
- /**
- * Change a user’s password.
- * @param currentPassword - The current password.
- * @param password - The new password.
- * @returns A `Promise` resolving to an `DataApiActionResult` object.
- */
- setPassword: (opts: { currentPassword: string; password: string }) => ReturnType;
- /**
- * Exchange an authorization code for an ID token.
- * @param authorizationCode - An authorization code received from an IdP.
- * @param redirectUri - A redirect URI used to obtain the authorization code.
- * @returns A `Promise` resolving when the redirection is complete.
- */
- exchangeCodeForIdToken: (opts: {
- authorizationCode: string;
- codeVerifier: string;
- redirectUri: string;
- }) => Promise;
- /**
- * Create a candidate with a nomination or nominations, then emails a registration link.
- * Expects a valid ID token in the cookies.
- * @param email - Email.
- * @param electionIds - Election IDs.
- * @param constituencyId - Constituency ID.
- * @returns A `Promise` resolving when the redirection is complete.
- */
- preregister: (opts: {
- email: string;
- nominations: Array<{ electionId: Id; constituencyId: Id }>;
- extra: {
- emailTemplate: {
- subject: string;
- text: string;
- html: string;
+ /**
+ * Check whether the registration key is valid.
+ * @param registrationKey - The registration key to check.
+ * @returns A `Promise` resolving to an `DataApiActionResult` object.
+ */
+ checkRegistrationKey: (opts: { registrationKey: string }) => ReturnType;
+ /**
+ * Activate an already registered user with the provided registration key and password.
+ * @param registrationKey - The registration key to check.
+ * @param password - The user’s password.
+ * @returns A `Promise` resolving to an `DataApiActionResult` object.
+ */
+ register: (opts: { registrationKey: string; password: string }) => ReturnType;
+ /**
+ * Logout the user and redirect to the login page.
+ * @returns A `Promise` resolving when the redirection is complete.
+ */
+ logout: () => Promise;
+ /**
+ * Exchange an authorization code for an ID token.
+ * @param authorizationCode - An authorization code received from an IdP.
+ * @param redirectUri - A redirect URI used to obtain the authorization code.
+ * @returns A `Promise` resolving when the redirection is complete.
+ */
+ exchangeCodeForIdToken: (opts: {
+ authorizationCode: string;
+ codeVerifier: string;
+ redirectUri: string;
+ }) => Promise;
+ /**
+ * Create a candidate with a nomination or nominations, then emails a registration link.
+ * Expects a valid ID token in the cookies.
+ * @param email - Email.
+ * @param electionIds - Election IDs.
+ * @param constituencyId - Constituency ID.
+ * @returns A `Promise` resolving when the redirection is complete.
+ */
+ preregister: (opts: {
+ email: string;
+ nominations: Array<{ electionId: Id; constituencyId: Id }>;
+ extra: {
+ emailTemplate: {
+ subject: string;
+ text: string;
+ html: string;
+ };
};
- };
- }) => Promise;
+ }) => Promise;
+ /**
+ * Clear the OIDC ID token.
+ */
+ clearIdToken: () => Promise;
- clearIdToken: () => Promise;
+ ////////////////////////////////////////////////////////////////////
+ // Other properties specific to CandidateContext
+ ////////////////////////////////////////////////////////////////////
- ////////////////////////////////////////////////////////////////////
- // Other properties specific to CandidateContext
- ////////////////////////////////////////////////////////////////////
-
- /**
- * An extended store that holds all data owned by the user. When subscribed to, it returns a composite of the initial data and any unsaved `Answer`s and properties. Dedicated methods are provided for loading, saving, setting or resetting data.
- *
- * NB. Before using the store, its `init` method must be called with the initial `CandidateUserData`.
- */
- userData: UserDataStore;
- /**
- * Holds the jwt token. NB. The context’s internal methods use it automatically for authentication.
- */
- authToken: Readable;
- /**
- * Holds the ID token claims.
- */
- idTokenClaims: Readable<{ firstName: string; lastName: string } | undefined>;
- /**
- * Holds the user’s email so it can be prefilled during password changes.
- */
- newUserEmail: Writable;
- /**
- * Whether the answers can be edited.
- */
- answersLocked: Readable;
- /**
- * Required info `Question`s.
- */
- requiredInfoQuestions: Readable>;
- /**
- * The required info `Question`s that are yet to be answered.
- */
- unansweredRequiredInfoQuestions: Readable>;
- /**
- * The opinion `Question`s that are yet to be answered.
- */
- unansweredOpinionQuestions: Readable>;
- /**
- * Whether the profile is fully complete.
- */
- profileComplete: Readable;
- /**
- * A locally stored store set to `true` when the user has completed the preregistration process.
- * Can be used to prompt the user to log in instead of preregistering again.
- */
- isPreregistered: Writable;
-};
+ /**
+ * An extended store that holds all data owned by the user. When subscribed to, it returns a composite of the initial data and any unsaved `Answer`s and properties. Dedicated methods are provided for loading, saving, setting or resetting data.
+ *
+ * NB. Before using the store, its `init` method must be called with the initial `CandidateUserData`.
+ */
+ userData: CandidateUserDataStore;
+ /**
+ * Holds the ID token claims.
+ */
+ idTokenClaims: Readable<{ firstName: string; lastName: string } | undefined>;
+ /**
+ * Holds the user’s email so it can be prefilled during password changes.
+ */
+ newUserEmail: Writable;
+ /**
+ * Whether the answers can be edited.
+ */
+ answersLocked: Readable;
+ /**
+ * Required info `Question`s.
+ */
+ requiredInfoQuestions: Readable>;
+ /**
+ * The required info `Question`s that are yet to be answered.
+ */
+ unansweredRequiredInfoQuestions: Readable>;
+ /**
+ * The opinion `Question`s that are yet to be answered.
+ */
+ unansweredOpinionQuestions: Readable>;
+ /**
+ * Whether the profile is fully complete.
+ */
+ profileComplete: Readable;
+ /**
+ * A locally stored store set to `true` when the user has completed the preregistration process.
+ * Can be used to prompt the user to log in instead of preregistering again.
+ */
+ isPreregistered: Writable;
+ };
diff --git a/frontend/src/lib/contexts/candidate/userDataStore.ts b/frontend/src/lib/contexts/candidate/candidateUserDataStore.ts
similarity index 95%
rename from frontend/src/lib/contexts/candidate/userDataStore.ts
rename to frontend/src/lib/contexts/candidate/candidateUserDataStore.ts
index ca9302216..474caff14 100644
--- a/frontend/src/lib/contexts/candidate/userDataStore.ts
+++ b/frontend/src/lib/contexts/candidate/candidateUserDataStore.ts
@@ -1,13 +1,13 @@
import { type Id } from '@openvaa/core';
import { ENTITY_TYPE, type Image } from '@openvaa/data';
import { derived, get, type Readable, writable } from 'svelte/store';
-import { prepareDataWriter } from './prepareDataWriter';
+import { prepareDataWriter } from '../utils/prepareDataWriter';
import { localStorageWritable } from '../utils/storageStore';
import type { LocalizedAnswer } from '@openvaa/app-shared';
import type { DataApiActionResult } from '$lib/api/base/actionResult.type';
import type { CandidateUserData, LocalizedAnswers, LocalizedCandidateData } from '$lib/api/base/dataWriter.type';
import type { UniversalDataWriter } from '$lib/api/base/universalDataWriter';
-import type { UserDataStore } from './userDataStore.type';
+import type { CandidateUserDataStore } from './candidateUserDataStore.type';
/**
* Create an extended store that holds all data owned by the user. When subscribed to, it returns a composite of the initial data and any unsaved `Answer`s and properties. The edited `Answer`s are stored in `localStorage` for persistence.
@@ -18,7 +18,7 @@ import type { UserDataStore } from './userDataStore.type';
* @param dataWriterPromise - A `Promise` resolving to `UniversalDataWriter` for saving data.
* @param locale - A read-only store that indicates the current locale, used for translating some data when it's fetched.
*/
-export function userDataStore({
+export function candidateUserDataStore({
answersLocked,
authToken,
dataWriterPromise,
@@ -28,7 +28,7 @@ export function userDataStore({
authToken: Readable;
dataWriterPromise: Promise;
locale: Readable;
-}): UserDataStore {
+}): CandidateUserDataStore {
////////////////////////////////////////////////////////////////////
// Internals
////////////////////////////////////////////////////////////////////
@@ -37,7 +37,10 @@ export function userDataStore({
const savedData = writable | undefined>();
// An internal store for holding edited answers
- const editedAnswers = localStorageWritable('CandidateContext-userDataStore-editedAnswers', {} as LocalizedAnswers);
+ const editedAnswers = localStorageWritable(
+ 'CandidateContext-candidateUserDataStore-editedAnswers',
+ {} as LocalizedAnswers
+ );
// An internal store for holding the edited image
const editedImage = writable();
diff --git a/frontend/src/lib/contexts/candidate/userDataStore.type.ts b/frontend/src/lib/contexts/candidate/candidateUserDataStore.type.ts
similarity index 97%
rename from frontend/src/lib/contexts/candidate/userDataStore.type.ts
rename to frontend/src/lib/contexts/candidate/candidateUserDataStore.type.ts
index 40306998d..2427c2069 100644
--- a/frontend/src/lib/contexts/candidate/userDataStore.type.ts
+++ b/frontend/src/lib/contexts/candidate/candidateUserDataStore.type.ts
@@ -9,7 +9,7 @@ import type { CandidateUserData, LocalizedCandidateData } from '$lib/api/base/da
*
* NB. Before using the store, its `init` method must be called with the initial `CandidateUserData`.
*/
-export type UserDataStore = Readable | undefined> & {
+export type CandidateUserDataStore = Readable | undefined> & {
/**
* Initialize the store with the full `CandidateUserData`.
*/
diff --git a/frontend/src/lib/contexts/candidate/index.ts b/frontend/src/lib/contexts/candidate/index.ts
index 247928c43..5668fd67e 100644
--- a/frontend/src/lib/contexts/candidate/index.ts
+++ b/frontend/src/lib/contexts/candidate/index.ts
@@ -1,3 +1,3 @@
export * from './candidateContext';
export * from './candidateContext.type';
-export * from './userDataStore.type';
+export * from './candidateUserDataStore.type';
diff --git a/frontend/src/lib/contexts/candidate/prepareDataWriter.ts b/frontend/src/lib/contexts/utils/prepareDataWriter.ts
similarity index 85%
rename from frontend/src/lib/contexts/candidate/prepareDataWriter.ts
rename to frontend/src/lib/contexts/utils/prepareDataWriter.ts
index 1ba2ca4df..f5cc6140b 100644
--- a/frontend/src/lib/contexts/candidate/prepareDataWriter.ts
+++ b/frontend/src/lib/contexts/utils/prepareDataWriter.ts
@@ -6,7 +6,7 @@ import type { UniversalDataWriter } from '$lib/api/base/universalDataWriter';
* Init and return a `DataWriter` instance from the provided promised import from `$lib/api/dataWriter`.
*/
export async function prepareDataWriter(dataWriterPromise: Promise): Promise {
- if (!browser) throw new Error('DataWriter methods in CandidateContext can only be called in a browser environment');
+ if (!browser) throw new Error('DataWriter methods in contexts can only be called in a browser environment');
const dataWriter = await dataWriterPromise;
if (!dataWriter)
throw new Error(
diff --git a/frontend/src/routes/[[lang=locale]]/+layout.svelte b/frontend/src/routes/[[lang=locale]]/+layout.svelte
index 20d5adbd8..3d4e9e9f0 100644
--- a/frontend/src/routes/[[lang=locale]]/+layout.svelte
+++ b/frontend/src/routes/[[lang=locale]]/+layout.svelte
@@ -32,6 +32,7 @@
import MaintenancePage from './MaintenancePage.svelte';
import type { DPDataType } from '$lib/api/base/dataTypes';
import type { LayoutData } from './$types';
+ import { initAuthContext } from '$lib/contexts/auth';
export let data: LayoutData;
@@ -45,6 +46,8 @@
const { appSettings, dataRoot, openFeedbackModal, popupQueue, sendTrackingEvent, startPageview, submitAllEvents, t } =
initAppContext();
initLayoutContext();
+ // TODO: Consider moving the candidate and admin apps to a (auth) folder with the AuthContext initialized there
+ initAuthContext();
////////////////////////////////////////////////////////////////////
// Provide globally used data and check all loaded data
From b3ebfc2df3f639e4a1cac9e8c7f5df612d26bd9d Mon Sep 17 00:00:00 2001
From: kallelongjuhani
Date: Wed, 13 Aug 2025 11:05:53 +0300
Subject: [PATCH 004/136] build: add `llm` and `argument-condensation` to
`frontend` deps
Also convert the build in both to follow the standard, non-dual build
paradigm.
---
frontend/package.json | 2 ++
frontend/tsconfig.json | 2 ++
packages/argument-condensation/package.json | 15 ++++++---------
packages/argument-condensation/tsconfig.cjs.json | 9 ---------
packages/argument-condensation/tsconfig.esm.json | 9 ---------
packages/llm/package.json | 15 ++++++---------
packages/llm/tsconfig.cjs.json | 13 -------------
packages/llm/tsconfig.esm.json | 11 -----------
yarn.lock | 6 +++++-
9 files changed, 21 insertions(+), 61 deletions(-)
delete mode 100644 packages/argument-condensation/tsconfig.cjs.json
delete mode 100644 packages/argument-condensation/tsconfig.esm.json
delete mode 100644 packages/llm/tsconfig.cjs.json
delete mode 100644 packages/llm/tsconfig.esm.json
diff --git a/frontend/package.json b/frontend/package.json
index 718801001..f4877f7e4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -63,9 +63,11 @@
"@capacitor/core": "^5.7.8",
"@capacitor/ios": "^5.7.8",
"@openvaa/app-shared": "workspace:^",
+ "@openvaa/argument-condensation": "workspace:^",
"@openvaa/core": "workspace:^",
"@openvaa/data": "workspace:^",
"@openvaa/filters": "workspace:^",
+ "@openvaa/llm": "workspace:^",
"@openvaa/matching": "workspace:^",
"@sveltekit-i18n/parser-icu": "^1.0.8",
"flat-cache": "^6.1.7",
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 5c735275a..b255a84e8 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -10,8 +10,10 @@
},
"references": [
{ "path": "../packages/app-shared/tsconfig.esm.json" },
+ { "path": "../packages/argument-condensation/tsconfig.json" },
{ "path": "../packages/core/tsconfig.json" },
{ "path": "../packages/data/tsconfig.json" },
+ { "path": "../packages/llm/tsconfig.json" },
{ "path": "../packages/matching/tsconfig.json" },
{ "path": "../packages/filters/tsconfig.json" }
]
diff --git a/packages/argument-condensation/package.json b/packages/argument-condensation/package.json
index 68e07928c..f28712337 100644
--- a/packages/argument-condensation/package.json
+++ b/packages/argument-condensation/package.json
@@ -3,26 +3,23 @@
"name": "@openvaa/argument-condensation",
"version": "0.1.0",
"scripts": {
- "package:cjs": "mkdir -p ./build/cjs/ && echo '{ \"type\": \"commonjs\" }' > ./build/cjs/package.json",
- "package:esm": "mkdir -p ./build/esm/ && echo '{ \"type\": \"module\" }' > ./build/esm/package.json",
- "build:cjs": "yarn package:cjs && yarn exec tsc --build tsconfig.cjs.json",
- "build:esm": "yarn package:esm && yarn exec tsc --build tsconfig.esm.json",
- "build": "yarn build:cjs && yarn build:esm",
+ "build": "yarn tsc --build && yarn tsc-esm-fix",
"test": "vitest run",
"test:watch": "vitest watch",
"dev:vis": "serve tools/visualization"
},
- "main": "./build/cjs/index.js",
- "module": "./build/esm/index.js",
+ "type": "module",
+ "module": "./build/index.js",
+ "types": "./build/index.d.ts",
"exports": {
- "import": "./build/esm/index.js",
- "require": "./build/cjs/index.js"
+ "import": "./build/index.js"
},
"devDependencies": {
"@openvaa/shared-config": "workspace:^",
"@types/js-yaml": "^4.0.9",
"dotenv": "^16.3.1",
"serve": "^14.2.3",
+ "tsc-esm-fix": "^3.1.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
},
diff --git a/packages/argument-condensation/tsconfig.cjs.json b/packages/argument-condensation/tsconfig.cjs.json
deleted file mode 100644
index c033a753f..000000000
--- a/packages/argument-condensation/tsconfig.cjs.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "module": "CommonJS",
- "moduleResolution": "node",
- "outDir": "./build/cjs",
- "tsBuildInfoFile": "./build/cjs/tsconfig.tsbuildinfo"
- }
-}
diff --git a/packages/argument-condensation/tsconfig.esm.json b/packages/argument-condensation/tsconfig.esm.json
deleted file mode 100644
index da72be423..000000000
--- a/packages/argument-condensation/tsconfig.esm.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "module": "ESNext",
- "moduleResolution": "node",
- "outDir": "./build/esm",
- "tsBuildInfoFile": "./build/esm/tsconfig.tsbuildinfo"
- }
-}
diff --git a/packages/llm/package.json b/packages/llm/package.json
index cd70ebc77..c67fbdce3 100644
--- a/packages/llm/package.json
+++ b/packages/llm/package.json
@@ -3,22 +3,19 @@
"name": "@openvaa/llm",
"version": "0.1.0",
"scripts": {
- "package:cjs": "mkdir -p ./build/cjs/ && echo '{ \"type\": \"commonjs\" }' > ./build/cjs/package.json",
- "package:esm": "mkdir -p ./build/esm/ && echo '{ \"type\": \"module\" }' > ./build/esm/package.json",
- "build:cjs": "yarn package:cjs && yarn tsc --build tsconfig.cjs.json",
- "build:esm": "yarn package:esm && yarn tsc --build tsconfig.esm.json",
- "build": "yarn build:cjs && yarn build:esm",
+ "build": "yarn tsc --build && yarn tsc-esm-fix",
"test": "vitest run",
"test:watch": "vitest"
},
- "main": "./build/cjs/index.js",
- "module": "./build/esm/index.js",
+ "type": "module",
+ "module": "./build/index.js",
+ "types": "./build/index.d.ts",
"exports": {
- "import": "./build/esm/index.js",
- "require": "./build/cjs/index.js"
+ "import": "./build/index.js"
},
"devDependencies": {
"@openvaa/shared-config": "workspace:^",
+ "tsc-esm-fix": "^3.1.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
},
diff --git a/packages/llm/tsconfig.cjs.json b/packages/llm/tsconfig.cjs.json
deleted file mode 100644
index ea24c50ff..000000000
--- a/packages/llm/tsconfig.cjs.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "target": "ESNext",
- "module": "CommonJS",
- "moduleResolution": "Node10",
- "forceConsistentCasingInFileNames": true,
- "allowJs": true,
-
- "outDir": "./build/cjs",
- "tsBuildInfoFile": "./build/cjs/tsconfig.tsbuildinfo"
- }
-}
diff --git a/packages/llm/tsconfig.esm.json b/packages/llm/tsconfig.esm.json
deleted file mode 100644
index 27ac013c3..000000000
--- a/packages/llm/tsconfig.esm.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "module": "ESNext",
- "target": "ESNext",
- "moduleResolution": "Bundler",
-
- "outDir": "./build/esm",
- "tsBuildInfoFile": "./build/esm/tsconfig.tsbuildinfo"
- }
-}
diff --git a/yarn.lock b/yarn.lock
index 66b1e0311..14fa567bc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4150,7 +4150,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@openvaa/argument-condensation@workspace:packages/argument-condensation":
+"@openvaa/argument-condensation@workspace:^, @openvaa/argument-condensation@workspace:packages/argument-condensation":
version: 0.0.0-use.local
resolution: "@openvaa/argument-condensation@workspace:packages/argument-condensation"
dependencies:
@@ -4162,6 +4162,7 @@ __metadata:
dotenv: "npm:^16.3.1"
js-yaml: "npm:^4.1.0"
serve: "npm:^14.2.3"
+ tsc-esm-fix: "npm:^3.1.2"
typescript: "npm:^5.7.3"
vitest: "npm:^2.1.8"
languageName: unknown
@@ -4215,9 +4216,11 @@ __metadata:
"@eslint/eslintrc": "npm:^3.2.0"
"@eslint/js": "npm:^9.17.0"
"@openvaa/app-shared": "workspace:^"
+ "@openvaa/argument-condensation": "workspace:^"
"@openvaa/core": "workspace:^"
"@openvaa/data": "workspace:^"
"@openvaa/filters": "workspace:^"
+ "@openvaa/llm": "workspace:^"
"@openvaa/matching": "workspace:^"
"@openvaa/shared-config": "workspace:^"
"@sveltejs/adapter-auto": "npm:^3.3.1"
@@ -4270,6 +4273,7 @@ __metadata:
"@openvaa/shared-config": "workspace:^"
jsonrepair: "npm:^3.13.0"
openai: "npm:^4.78.1"
+ tsc-esm-fix: "npm:^3.1.2"
typescript: "npm:^5.7.3"
vitest: "npm:^2.1.8"
languageName: unknown
From 0ec0f8e584d80c4785f88db710c6de32a61a5f82 Mon Sep 17 00:00:00 2001
From: kallelongjuhani
Date: Wed, 13 Aug 2025 11:10:04 +0300
Subject: [PATCH 005/136] refactor: `CandidateLoginError` messages
---
frontend/src/lib/candidate/utils/loginError.ts | 13 +++++++++----
.../[[lang=locale]]/candidate/login/+page.svelte | 10 +++++-----
2 files changed, 14 insertions(+), 9 deletions(-)
diff --git a/frontend/src/lib/candidate/utils/loginError.ts b/frontend/src/lib/candidate/utils/loginError.ts
index c9507bcec..b05bee8d2 100644
--- a/frontend/src/lib/candidate/utils/loginError.ts
+++ b/frontend/src/lib/candidate/utils/loginError.ts
@@ -3,14 +3,14 @@ import type { TranslationKey } from '$types';
/**
* Get the translation key for the given error code.
*/
-export function getErrorTranslationKey(error: CandidateLoginError | string | null): TranslationKey | undefined {
+export function getErrorTranslationKey(error: CandidateLoginError | null): TranslationKey | undefined {
if (error == null || !(error in CANDIDATE_LOGIN_ERROR)) return undefined;
return CANDIDATE_LOGIN_ERROR[error];
}
-const CANDIDATE_LOGIN_ERROR: Record = {
+const CANDIDATE_LOGIN_ERROR: Record = {
candidateNoNomination: 'candidateApp.error.candidateNoNomination',
- loginFailed: 'candidateApp.error.loginFailed',
+ loginFailed: 'error.loginFailed',
nominationNoElection: 'candidateApp.error.nominationNoElection',
userNoCandidate: 'candidateApp.error.userNoCandidate',
userNotAuthorized: 'error.403'
@@ -19,4 +19,9 @@ const CANDIDATE_LOGIN_ERROR: Record = {
/**
* The allowed error codes for candidate login to be displayed on the login page. These are subkeys of `candidateApp.error.` translations.
*/
-export type CandidateLoginError = keyof typeof CANDIDATE_LOGIN_ERROR;
+export type CandidateLoginError =
+ | 'candidateNoNomination'
+ | 'loginFailed'
+ | 'nominationNoElection'
+ | 'userNoCandidate'
+ | 'userNotAuthorized';
diff --git a/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte b/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte
index 05bd0ae56..2b7eddc9d 100644
--- a/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte
+++ b/frontend/src/routes/[[lang=locale]]/candidate/login/+page.svelte
@@ -25,7 +25,7 @@
import { applyAction, enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
- import { getErrorTranslationKey } from '$candidate/utils/loginError';
+ import { getErrorTranslationKey, type CandidateLoginError } from '$candidate/utils/loginError';
import { PasswordField } from '$lib/candidate/components/passwordField';
import { Button } from '$lib/components/button';
import { ErrorMessage } from '$lib/components/errorMessage';
@@ -47,7 +47,7 @@
// Handle form and error messages
////////////////////////////////////////////////////////////////////
- const errorParam = $page.url.searchParams.get('errorMessage');
+ const errorParam = $page.url.searchParams.get('errorMessage') as CandidateLoginError | null;
const redirectTo = $page.url.searchParams.get('redirectTo');
let canSubmit: boolean;
@@ -132,7 +132,7 @@
status = 'error';
errorMessage =
result.status === 400
- ? $t('candidateApp.login.wrongEmailOrPassword')
+ ? $t('error.wrongEmailOrPassword')
: result.status === 403
? $t('error.403')
: $t('candidateApp.login.unknownError');
@@ -151,7 +151,7 @@
: $t('candidateApp.login.enterEmailAndPassword')}
{/if}
-
+
From 5a23036e48a98e6ac8ad32cc10b90cbd9431ce63 Mon Sep 17 00:00:00 2001
From: kallelongjuhani
Date: Wed, 13 Aug 2025 11:12:04 +0300
Subject: [PATCH 006/136] feat: Admin App
Add a scaffold for the Admin App on the `/admin` route.
Add `AdminContext` for use with the app.
Reorganize common translations used in both Candidate and Admin Apps.
Within the Admin App, add an incomplete draft of the argument
condensation function.
Add preliminary translations for some parts of the Admin App, which are
not yet available.
---
docs/admin-app/README.md | 7 +
frontend/src/lib/admin/utils/loginError.ts | 19 +++
frontend/src/lib/api/base/dataWriter.type.ts | 3 +
.../src/lib/contexts/admin/adminContext.ts | 44 ++++++
.../lib/contexts/admin/adminContext.type.ts | 12 ++
frontend/src/lib/contexts/admin/index.ts | 2 +
.../src/lib/contexts/app/appContext.type.ts | 2 +-
.../logoutButton/LogoutButton.svelte | 39 +++++
.../logoutButton/LogoutButton.type.ts | 9 ++
.../dynamic-components/logoutButton/index.ts | 1 +
.../navigation/admin/AdminNav.svelte | 59 ++++++++
.../navigation/admin/index.ts | 1 +
.../en/adminApp.argumentCondensation.json | 4 +
.../i18n/translations/en/adminApp.common.json | 8 +
.../i18n/translations/en/adminApp.error.json | 5 +
.../en/adminApp.factorAnalysis.json | 20 +++
.../i18n/translations/en/adminApp.login.json | 17 +++
.../en/adminApp.notSupported.json | 5 +
.../en/adminApp.questionInfo.json | 25 ++++
.../translations/en/candidateApp.common.json | 2 -
.../translations/en/candidateApp.error.json | 1 -
.../translations/en/candidateApp.login.json | 1 -
.../src/lib/i18n/translations/en/common.json | 2 +
.../src/lib/i18n/translations/en/error.json | 4 +-
.../fi/adminApp.argumentCondensation.json | 4 +
.../i18n/translations/fi/adminApp.common.json | 8 +
.../i18n/translations/fi/adminApp.error.json | 5 +
.../fi/adminApp.factorAnalysis.json | 20 +++
.../i18n/translations/fi/adminApp.login.json | 17 +++
.../fi/adminApp.notSupported.json | 5 +
.../fi/adminApp.questionInfo.json | 25 ++++
.../translations/fi/candidateApp.common.json | 2 -
.../translations/fi/candidateApp.error.json | 1 -
.../translations/fi/candidateApp.login.json | 1 -
.../src/lib/i18n/translations/fi/common.json | 2 +
.../src/lib/i18n/translations/fi/error.json | 4 +-
frontend/src/lib/i18n/translations/index.ts | 7 +
.../sv/adminApp.argumentCondensation.json | 4 +
.../i18n/translations/sv/adminApp.common.json | 8 +
.../i18n/translations/sv/adminApp.error.json | 5 +
.../sv/adminApp.factorAnalysis.json | 20 +++
.../i18n/translations/sv/adminApp.login.json | 17 +++
.../sv/adminApp.notSupported.json | 5 +
.../sv/adminApp.questionInfo.json | 25 ++++
.../translations/sv/candidateApp.common.json | 2 -
.../translations/sv/candidateApp.error.json | 1 -
.../translations/sv/candidateApp.login.json | 1 -
.../src/lib/i18n/translations/sv/common.json | 2 +
.../src/lib/i18n/translations/sv/error.json | 4 +-
frontend/src/lib/server/llm/llmProvider.ts | 13 ++
.../src/lib/types/generated/translationKey.ts | 66 +++++++-
frontend/src/lib/utils/route/route.ts | 11 +-
.../src/routes/[[lang=locale]]/Banner.svelte | 11 +-
.../admin/(protected)/+layout.svelte | 52 +++++++
.../admin/(protected)/+layout.ts | 69 +++++++++
.../admin/(protected)/+page.svelte | 39 +++++
.../argument-condensation/+layout.svelte | 58 +++++++
.../argument-condensation/+layout.ts | 18 +++
.../argument-condensation/+page.server.ts | 96 ++++++++++++
.../argument-condensation/+page.svelte | 141 ++++++++++++++++++
.../[[lang=locale]]/admin/+layout.server.ts | 10 ++
.../[[lang=locale]]/admin/+layout.svelte | 54 +++++++
.../admin/login/+page.server.ts | 39 +++++
.../[[lang=locale]]/admin/login/+page.svelte | 139 +++++++++++++++++
.../(protected)/settings/+page.svelte | 2 +-
.../candidate/forgot-password/+page.svelte | 4 +-
.../(authenticated)/email/+page.svelte | 8 +-
frontend/static/images/hero-admin.png | Bin 0 -> 1465322 bytes
.../src/settings/dynamicSettings.ts | 1 +
.../src/settings/dynamicSettings.type.ts | 4 +
.../app-shared/src/settings/staticSettings.ts | 3 +-
.../src/settings/staticSettings.type.ts | 23 +--
72 files changed, 1306 insertions(+), 42 deletions(-)
create mode 100644 docs/admin-app/README.md
create mode 100644 frontend/src/lib/admin/utils/loginError.ts
create mode 100644 frontend/src/lib/contexts/admin/adminContext.ts
create mode 100644 frontend/src/lib/contexts/admin/adminContext.type.ts
create mode 100644 frontend/src/lib/contexts/admin/index.ts
create mode 100644 frontend/src/lib/dynamic-components/logoutButton/LogoutButton.svelte
create mode 100644 frontend/src/lib/dynamic-components/logoutButton/LogoutButton.type.ts
create mode 100644 frontend/src/lib/dynamic-components/logoutButton/index.ts
create mode 100644 frontend/src/lib/dynamic-components/navigation/admin/AdminNav.svelte
create mode 100644 frontend/src/lib/dynamic-components/navigation/admin/index.ts
create mode 100644 frontend/src/lib/i18n/translations/en/adminApp.argumentCondensation.json
create mode 100644 frontend/src/lib/i18n/translations/en/adminApp.common.json
create mode 100644 frontend/src/lib/i18n/translations/en/adminApp.error.json
create mode 100644 frontend/src/lib/i18n/translations/en/adminApp.factorAnalysis.json
create mode 100644 frontend/src/lib/i18n/translations/en/adminApp.login.json
create mode 100644 frontend/src/lib/i18n/translations/en/adminApp.notSupported.json
create mode 100644 frontend/src/lib/i18n/translations/en/adminApp.questionInfo.json
create mode 100644 frontend/src/lib/i18n/translations/fi/adminApp.argumentCondensation.json
create mode 100644 frontend/src/lib/i18n/translations/fi/adminApp.common.json
create mode 100644 frontend/src/lib/i18n/translations/fi/adminApp.error.json
create mode 100644 frontend/src/lib/i18n/translations/fi/adminApp.factorAnalysis.json
create mode 100644 frontend/src/lib/i18n/translations/fi/adminApp.login.json
create mode 100644 frontend/src/lib/i18n/translations/fi/adminApp.notSupported.json
create mode 100644 frontend/src/lib/i18n/translations/fi/adminApp.questionInfo.json
create mode 100644 frontend/src/lib/i18n/translations/sv/adminApp.argumentCondensation.json
create mode 100644 frontend/src/lib/i18n/translations/sv/adminApp.common.json
create mode 100644 frontend/src/lib/i18n/translations/sv/adminApp.error.json
create mode 100644 frontend/src/lib/i18n/translations/sv/adminApp.factorAnalysis.json
create mode 100644 frontend/src/lib/i18n/translations/sv/adminApp.login.json
create mode 100644 frontend/src/lib/i18n/translations/sv/adminApp.notSupported.json
create mode 100644 frontend/src/lib/i18n/translations/sv/adminApp.questionInfo.json
create mode 100644 frontend/src/lib/server/llm/llmProvider.ts
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.svelte
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.ts
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/(protected)/+page.svelte
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.svelte
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.ts
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.server.ts
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.svelte
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/+layout.server.ts
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/+layout.svelte
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/login/+page.server.ts
create mode 100644 frontend/src/routes/[[lang=locale]]/admin/login/+page.svelte
create mode 100644 frontend/static/images/hero-admin.png
diff --git a/docs/admin-app/README.md b/docs/admin-app/README.md
new file mode 100644
index 000000000..0306e15ab
--- /dev/null
+++ b/docs/admin-app/README.md
@@ -0,0 +1,7 @@
+# Admin App
+
+The Admin App is part of the frontend package and contained in the `/admin` route.
+
+## Mock Data
+
+By default, [mock data](/backend/vaa-strapi/src/functions/mockData/mockAdmins.json) includes the admin user `mock.admin@example.com` with the password `Password1!` that can be used to test the admin app.
diff --git a/frontend/src/lib/admin/utils/loginError.ts b/frontend/src/lib/admin/utils/loginError.ts
new file mode 100644
index 000000000..8c78ac6a3
--- /dev/null
+++ b/frontend/src/lib/admin/utils/loginError.ts
@@ -0,0 +1,19 @@
+import type { TranslationKey } from '$types';
+
+/**
+ * Get the translation key for the given error code.
+ */
+export function getErrorTranslationKey(error: LoginError | null): TranslationKey | undefined {
+ if (error == null || !(error in LOGIN_ERROR)) return undefined;
+ return LOGIN_ERROR[error];
+}
+
+const LOGIN_ERROR: Record = {
+ loginFailed: 'error.loginFailed',
+ userNotAuthorized: 'error.403'
+} as const;
+
+/**
+ * The allowed error codes for candidate login to be displayed on the login page. These are subkeys of `error` translations.
+ */
+export type LoginError = 'loginFailed' | 'userNotAuthorized';
diff --git a/frontend/src/lib/api/base/dataWriter.type.ts b/frontend/src/lib/api/base/dataWriter.type.ts
index 9670c69a0..526d4b911 100644
--- a/frontend/src/lib/api/base/dataWriter.type.ts
+++ b/frontend/src/lib/api/base/dataWriter.type.ts
@@ -66,6 +66,9 @@ export interface DataWriter {
} & WithAuth
) => DWReturnType;
+ /**
+ * Clear the OIDC ID token.
+ */
clearIdToken: () => DWReturnType;
////////////////////////////////////////////////////////////////////
diff --git a/frontend/src/lib/contexts/admin/adminContext.ts b/frontend/src/lib/contexts/admin/adminContext.ts
new file mode 100644
index 000000000..e6a8e6f2a
--- /dev/null
+++ b/frontend/src/lib/contexts/admin/adminContext.ts
@@ -0,0 +1,44 @@
+import { error } from '@sveltejs/kit';
+import { getContext, hasContext, setContext } from 'svelte';
+import { writable } from 'svelte/store';
+import { getAppContext } from '../app';
+import { getAuthContext } from '../auth';
+import type { BasicUserData } from '$lib/api/base/dataWriter.type';
+import type { AdminContext } from './adminContext.type';
+
+const CONTEXT_KEY = Symbol('admin');
+
+export function getAdminContext(): AdminContext {
+ if (!hasContext(CONTEXT_KEY)) error(500, 'getAdminContext() called before initAdminContext()');
+ return getContext(CONTEXT_KEY);
+}
+
+export function initAdminContext(): AdminContext {
+ if (hasContext(CONTEXT_KEY)) error(500, 'initAdminContext() called for a second time');
+
+ ////////////////////////////////////////////////////////////
+ // Inheritance from other Contexts
+ ////////////////////////////////////////////////////////////
+
+ const appContext = getAppContext();
+ const authContext = getAuthContext();
+
+ ////////////////////////////////////////////////////////////////////
+ // Common contents
+ ////////////////////////////////////////////////////////////////////
+
+ const userData = writable(undefined);
+
+ ////////////////////////////////////////////////////////////////////
+ // Admin functions
+ ////////////////////////////////////////////////////////////////////
+
+ const adminContext: AdminContext = {
+ ...appContext,
+ ...authContext,
+ userData
+ };
+
+ setContext(CONTEXT_KEY, adminContext);
+ return adminContext;
+}
diff --git a/frontend/src/lib/contexts/admin/adminContext.type.ts b/frontend/src/lib/contexts/admin/adminContext.type.ts
new file mode 100644
index 000000000..acc4432f4
--- /dev/null
+++ b/frontend/src/lib/contexts/admin/adminContext.type.ts
@@ -0,0 +1,12 @@
+import type { Writable } from 'svelte/store';
+import type { BasicUserData } from '$lib/api/base/dataWriter.type';
+import type { AppContext } from '../app';
+import type { AuthContext } from '../auth';
+
+export type AdminContext = AppContext &
+ AuthContext & {
+ /**
+ * Store for user data
+ */
+ userData: Writable;
+ };
diff --git a/frontend/src/lib/contexts/admin/index.ts b/frontend/src/lib/contexts/admin/index.ts
new file mode 100644
index 000000000..5f867bc9c
--- /dev/null
+++ b/frontend/src/lib/contexts/admin/index.ts
@@ -0,0 +1,2 @@
+export * from './adminContext';
+export * from './adminContext.type';
diff --git a/frontend/src/lib/contexts/app/appContext.type.ts b/frontend/src/lib/contexts/app/appContext.type.ts
index a9ce0ff80..5be4e5364 100644
--- a/frontend/src/lib/contexts/app/appContext.type.ts
+++ b/frontend/src/lib/contexts/app/appContext.type.ts
@@ -80,4 +80,4 @@ export type AppContext = ComponentContext &
/**
* The possible types of the application
*/
-export type AppType = 'candidate' | 'voter' | undefined;
+export type AppType = 'admin' | 'candidate' | 'voter' | undefined;
diff --git a/frontend/src/lib/dynamic-components/logoutButton/LogoutButton.svelte b/frontend/src/lib/dynamic-components/logoutButton/LogoutButton.svelte
new file mode 100644
index 000000000..23f60d4a7
--- /dev/null
+++ b/frontend/src/lib/dynamic-components/logoutButton/LogoutButton.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/frontend/src/lib/dynamic-components/logoutButton/LogoutButton.type.ts b/frontend/src/lib/dynamic-components/logoutButton/LogoutButton.type.ts
new file mode 100644
index 000000000..f209662c8
--- /dev/null
+++ b/frontend/src/lib/dynamic-components/logoutButton/LogoutButton.type.ts
@@ -0,0 +1,9 @@
+import type { ButtonProps } from '$lib/components/button';
+import type { Route } from '$lib/utils/route';
+
+export type LogoutButtonProps = Partial & {
+ /**
+ * The route to redirect to after logging out. Default `Home`.
+ */
+ redirectTo?: Route;
+};
diff --git a/frontend/src/lib/dynamic-components/logoutButton/index.ts b/frontend/src/lib/dynamic-components/logoutButton/index.ts
new file mode 100644
index 000000000..c47c77e54
--- /dev/null
+++ b/frontend/src/lib/dynamic-components/logoutButton/index.ts
@@ -0,0 +1 @@
+export { default as LogoutButton } from './LogoutButton.svelte';
diff --git a/frontend/src/lib/dynamic-components/navigation/admin/AdminNav.svelte b/frontend/src/lib/dynamic-components/navigation/admin/AdminNav.svelte
new file mode 100644
index 000000000..015643138
--- /dev/null
+++ b/frontend/src/lib/dynamic-components/navigation/admin/AdminNav.svelte
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+ {#if $authToken}
+
+
+
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/dynamic-components/navigation/admin/index.ts b/frontend/src/lib/dynamic-components/navigation/admin/index.ts
new file mode 100644
index 000000000..fbafe1b5e
--- /dev/null
+++ b/frontend/src/lib/dynamic-components/navigation/admin/index.ts
@@ -0,0 +1 @@
+export { default as AdminNav } from './AdminNav.svelte';
diff --git a/frontend/src/lib/i18n/translations/en/adminApp.argumentCondensation.json b/frontend/src/lib/i18n/translations/en/adminApp.argumentCondensation.json
new file mode 100644
index 000000000..7217ea0df
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/en/adminApp.argumentCondensation.json
@@ -0,0 +1,4 @@
+{
+ "title": "Argument Condensation",
+ "description": "Condense and manage arguments for election questions."
+}
diff --git a/frontend/src/lib/i18n/translations/en/adminApp.common.json b/frontend/src/lib/i18n/translations/en/adminApp.common.json
new file mode 100644
index 000000000..89565eb1f
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/en/adminApp.common.json
@@ -0,0 +1,8 @@
+{
+ "home": "Home",
+ "description": "Use the tools below to manage the voting advice application.",
+ "notAccessible": {
+ "content": "The Admin App has currently been configured to be unavailable.",
+ "title": "Sorry, the Admin App is not available"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/en/adminApp.error.json b/frontend/src/lib/i18n/translations/en/adminApp.error.json
new file mode 100644
index 000000000..9b02b69f7
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/en/adminApp.error.json
@@ -0,0 +1,5 @@
+{
+ "unauthorized": "You are not authorized to access this area",
+ "unauthorizedDescription": "Your account has been authenticated, but you don't have the required admin privileges.",
+ "unauthorizedContact": "This section is reserved for system administrators only. If you believe you should have access, please contact the system administrator."
+}
diff --git a/frontend/src/lib/i18n/translations/en/adminApp.factorAnalysis.json b/frontend/src/lib/i18n/translations/en/adminApp.factorAnalysis.json
new file mode 100644
index 000000000..5eafc8e60
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/en/adminApp.factorAnalysis.json
@@ -0,0 +1,20 @@
+{
+ "title": "Factor Analysis",
+ "description": "Analyze and manage factors for the election questions.",
+ "compute": {
+ "title": "Compute factors",
+ "description": "Compute the latent factors from the answers given by candidates and parties.",
+ "selectElections": "Select the elections for which to compute the factors.",
+ "button": "Compute factors",
+ "buttonLoading": "Computing factors...",
+ "mayTakeTime": "This may take some time.",
+ "noElections": "No elections available",
+ "candidates": "candidates",
+ "parties": {
+ "some": "and {count} parties",
+ "none": "and no parties"
+ },
+ "haveAnswered": "have answered",
+ "error": "Failed to compute factors"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/en/adminApp.login.json b/frontend/src/lib/i18n/translations/en/adminApp.login.json
new file mode 100644
index 000000000..4b24ed90f
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/en/adminApp.login.json
@@ -0,0 +1,17 @@
+{
+ "title": "Admin Login",
+ "appTitle": "Election Compass",
+ "appSubtitle": "Administration",
+ "instructions": "Enter your login details.",
+ "email": "Email",
+ "password": "Password",
+ "button": "Login",
+ "forgotPassword": "Forgot Password?",
+ "errors": {
+ "invalidCredentials": "Invalid email or password",
+ "genericError": "An error occurred while trying to log in. Please try again.",
+ "unauthorized": "You need admin privileges to access this area",
+ "sessionExpired": "Your session has expired, please log in again",
+ "unknown": "An unknown error occurred"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/en/adminApp.notSupported.json b/frontend/src/lib/i18n/translations/en/adminApp.notSupported.json
new file mode 100644
index 000000000..3f4bb2109
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/en/adminApp.notSupported.json
@@ -0,0 +1,5 @@
+{
+ "title": "Admin App Not Supported",
+ "content": "The admin app is not supported with the current data adapter.",
+ "heroEmoji": "🔧"
+}
diff --git a/frontend/src/lib/i18n/translations/en/adminApp.questionInfo.json b/frontend/src/lib/i18n/translations/en/adminApp.questionInfo.json
new file mode 100644
index 000000000..b7b8b3c86
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/en/adminApp.questionInfo.json
@@ -0,0 +1,25 @@
+{
+ "title": "Question Info Generation",
+ "description": "Generate and manage information for election questions.",
+ "pageDescription": "Generate or edit the background information for questions.",
+ "generate": {
+ "title": "Generate info",
+ "description": "Generate the information overwriting any infos generated earlier.",
+ "questionType": "Select questions to generate info for",
+ "allQuestions": "All questions",
+ "selectedQuestions": "Selected questions",
+ "selectButton": "Select...",
+ "button": "Generate infos",
+ "buttonLoading": "Generating infos...",
+ "mayTakeTime": "This may take some time.",
+ "error": "Failed to generate info",
+ "noQuestionSelected": "Please select at least one question"
+ },
+ "edit": {
+ "title": "Edit infos",
+ "description": "Edit the existing information. You can either edit it directly using a JSON editor or download it as CSV and then upload the edited information.",
+ "editButton": "Edit the information",
+ "downloadButton": "Download the information as CSV",
+ "uploadButton": "Upload the information you have edited as CSV"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/en/candidateApp.common.json b/frontend/src/lib/i18n/translations/en/candidateApp.common.json
index 3d02801ae..244811443 100644
--- a/frontend/src/lib/i18n/translations/en/candidateApp.common.json
+++ b/frontend/src/lib/i18n/translations/en/candidateApp.common.json
@@ -2,8 +2,6 @@
"contactSupport": "Contact support",
"continueFilling": "Continue filling",
"editingNotAllowed": "Editing of your answers is no longer possible, but you can still view them. Please contact support if there are some errors in the data.",
- "email": "Email",
- "emailPlaceholder": "Email",
"fullyCompleted": "Congratulations! You have filled all of the information needed for the Election Compass.",
"greeting": "Hello, {username}!",
"home": "Start",
diff --git a/frontend/src/lib/i18n/translations/en/candidateApp.error.json b/frontend/src/lib/i18n/translations/en/candidateApp.error.json
index 18ef51cb1..62e6fd7da 100644
--- a/frontend/src/lib/i18n/translations/en/candidateApp.error.json
+++ b/frontend/src/lib/i18n/translations/en/candidateApp.error.json
@@ -1,6 +1,5 @@
{
"candidateNoNomination": "The candidate associated with the logged in user has no nomination. Please contact support.",
- "loginFailed": "Login failed. Please check your username and password.",
"nominationNoElection": "The nominatinon associated with the candidate has no election. Please contact support.",
"saveFailed": "We’re terribly sorry, there was a problem saving your answers. Please try again and if the problem persists, please contact support.",
"userNoCandidate": "The logged in user has no candidate info tied to it. Please contact support.",
diff --git a/frontend/src/lib/i18n/translations/en/candidateApp.login.json b/frontend/src/lib/i18n/translations/en/candidateApp.login.json
index 702f4525a..4eed39e31 100644
--- a/frontend/src/lib/i18n/translations/en/candidateApp.login.json
+++ b/frontend/src/lib/i18n/translations/en/candidateApp.login.json
@@ -3,6 +3,5 @@
"forgotPassword": "Forgot Password?",
"title": "Sign in",
"unknownError": "There was an error with your user data. Please contact support if the issue persists.",
- "wrongEmailOrPassword": "Wrong email or password",
"answersLockedInfo": "Registration or editing answers is no longer possible, but you can still log in to view your information."
}
diff --git a/frontend/src/lib/i18n/translations/en/common.json b/frontend/src/lib/i18n/translations/en/common.json
index acd34932f..15f4693cc 100644
--- a/frontend/src/lib/i18n/translations/en/common.json
+++ b/frontend/src/lib/i18n/translations/en/common.json
@@ -29,6 +29,8 @@
"faction": "Election Symbol",
"organization": "Election Symbol"
},
+ "email": "Email",
+ "emailPlaceholder": "Email",
"expandOrCollapse": "Expand or collapse this section",
"faction": {
"plural": "factions",
diff --git a/frontend/src/lib/i18n/translations/en/error.json b/frontend/src/lib/i18n/translations/en/error.json
index 844802304..b7caf38e9 100644
--- a/frontend/src/lib/i18n/translations/en/error.json
+++ b/frontend/src/lib/i18n/translations/en/error.json
@@ -5,7 +5,9 @@
"content": "
We're very sorry for the inconvenience. You can try to go back and reload the page and see if the problem persists.
If you still experience the problem, please send an email to {adminEmailLink} and describe the problem. Thank you!
",
"default": "Something went wrong, sorry!",
"general": "Something went wrong",
+ "loginFailed": "Login failed. Please check your username and password.",
"noNominations": "There are no candidates or parties in your constituency who have responded to the Election Compass.",
"noQuestions": "There are no questions related to your constituency in the Election Compass yet.",
- "unsupportedQuestion": "This question cannot be shown, sorry!"
+ "unsupportedQuestion": "This question cannot be shown, sorry!",
+ "wrongEmailOrPassword": "Wrong email or password"
}
diff --git a/frontend/src/lib/i18n/translations/fi/adminApp.argumentCondensation.json b/frontend/src/lib/i18n/translations/fi/adminApp.argumentCondensation.json
new file mode 100644
index 000000000..574025c87
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/fi/adminApp.argumentCondensation.json
@@ -0,0 +1,4 @@
+{
+ "title": "Argumenttien tiivistäminen",
+ "description": "Tiivistä ja hallinnoi vaalikysymysten argumentteja."
+}
diff --git a/frontend/src/lib/i18n/translations/fi/adminApp.common.json b/frontend/src/lib/i18n/translations/fi/adminApp.common.json
new file mode 100644
index 000000000..e21867d7b
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/fi/adminApp.common.json
@@ -0,0 +1,8 @@
+{
+ "home": "Etusivu",
+ "description": "Käytä alla olevia työkaluja hallitaksesi vaalikonetta.",
+ "notAccessible": {
+ "content": "Pääsy ylläpitonäkymään on tällä hetkellä poistettu käytöstä.",
+ "title": "Pahoittelut, ylläpitonäkymä ei ole käytettävissä"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/fi/adminApp.error.json b/frontend/src/lib/i18n/translations/fi/adminApp.error.json
new file mode 100644
index 000000000..b0052cebc
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/fi/adminApp.error.json
@@ -0,0 +1,5 @@
+{
+ "unauthorized": "Sinulla ei ole oikeutta tähän alueeseen",
+ "unauthorizedDescription": "Tilisi on todennettu, mutta sinulla ei ole tarvittavia ylläpitäjän oikeuksia.",
+ "unauthorizedContact": "Tämä osio on varattu vain järjestelmänvalvojille. Jos uskot, että sinun pitäisi päästä tähän osioon, ota yhteyttä järjestelmänvalvojaan."
+}
diff --git a/frontend/src/lib/i18n/translations/fi/adminApp.factorAnalysis.json b/frontend/src/lib/i18n/translations/fi/adminApp.factorAnalysis.json
new file mode 100644
index 000000000..aa708b669
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/fi/adminApp.factorAnalysis.json
@@ -0,0 +1,20 @@
+{
+ "title": "Faktorianalyysi",
+ "description": "Analysoi ja hallinnoi vaalikysymysten faktoreita.",
+ "compute": {
+ "title": "Laske faktorit",
+ "description": "Laske latentteja faktoreita ehdokkaiden ja puolueiden vastauksista.",
+ "selectElections": "Valitse vaalit, joille faktorit lasketaan.",
+ "button": "Laske faktorit",
+ "buttonLoading": "Lasketaan faktoreita...",
+ "mayTakeTime": "Tämä saattaa kestää hetken.",
+ "noElections": "Ei vaaleja saatavilla",
+ "candidates": "ehdokasta",
+ "parties": {
+ "some": "ja {count} puoluetta",
+ "none": "eikä yhtään puoluetta"
+ },
+ "haveAnswered": "on vastannut",
+ "error": "Faktoreiden laskeminen epäonnistui"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/fi/adminApp.login.json b/frontend/src/lib/i18n/translations/fi/adminApp.login.json
new file mode 100644
index 000000000..dfdaa20e5
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/fi/adminApp.login.json
@@ -0,0 +1,17 @@
+{
+ "title": "Ylläpitäjän kirjautuminen",
+ "appTitle": "Vaalikone",
+ "appSubtitle": "Ylläpito",
+ "instructions": "Syötä kirjautumistietosi.",
+ "email": "Sähköposti",
+ "password": "Salasana",
+ "button": "Kirjaudu",
+ "forgotPassword": "Unohditko salasanasi?",
+ "errors": {
+ "invalidCredentials": "Virheellinen sähköposti tai salasana",
+ "genericError": "Kirjautumisessa tapahtui virhe. Yritä uudelleen.",
+ "unauthorized": "Tarvitset ylläpitäjän oikeudet päästäksesi tähän osioon",
+ "sessionExpired": "Istuntosi on vanhentunut, kirjaudu uudelleen",
+ "unknown": "Tuntematon virhe tapahtui"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/fi/adminApp.notSupported.json b/frontend/src/lib/i18n/translations/fi/adminApp.notSupported.json
new file mode 100644
index 000000000..9be790672
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/fi/adminApp.notSupported.json
@@ -0,0 +1,5 @@
+{
+ "title": "Ylläpitosovellusta ei tueta",
+ "content": "Ylläpitosovellusta ei tueta nykyisellä data-adapterilla.",
+ "heroEmoji": "🔧"
+}
diff --git a/frontend/src/lib/i18n/translations/fi/adminApp.questionInfo.json b/frontend/src/lib/i18n/translations/fi/adminApp.questionInfo.json
new file mode 100644
index 000000000..48a26b338
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/fi/adminApp.questionInfo.json
@@ -0,0 +1,25 @@
+{
+ "title": "Kysymystietojen luominen",
+ "description": "Luo ja hallinnoi vaalikysymysten tietoja.",
+ "pageDescription": "Luo tai muokkaa kysymysten taustatietoja.",
+ "generate": {
+ "title": "Luo tiedot",
+ "description": "Luo tiedot, jotka korvaavat aiemmin luodut tiedot.",
+ "questionType": "Valitse kysymykset, joille tiedot luodaan",
+ "allQuestions": "Kaikki kysymykset",
+ "selectedQuestions": "Valitut kysymykset",
+ "selectButton": "Valitse...",
+ "button": "Luo tiedot",
+ "buttonLoading": "Luodaan tietoja...",
+ "mayTakeTime": "Tämä saattaa kestää hetken.",
+ "error": "Tietojen luominen epäonnistui",
+ "noQuestionSelected": "Valitse vähintään yksi kysymys"
+ },
+ "edit": {
+ "title": "Muokkaa tietoja",
+ "description": "Muokkaa olemassa olevia tietoja. Voit joko muokata niitä suoraan JSON-editorilla tai ladata ne CSV-tiedostona ja ladata sitten muokatut tiedot.",
+ "editButton": "Muokkaa tietoja",
+ "downloadButton": "Lataa tiedot CSV-muodossa",
+ "uploadButton": "Lataa muokatut tiedot CSV-muodossa"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/fi/candidateApp.common.json b/frontend/src/lib/i18n/translations/fi/candidateApp.common.json
index 3d6a59f39..518291db7 100644
--- a/frontend/src/lib/i18n/translations/fi/candidateApp.common.json
+++ b/frontend/src/lib/i18n/translations/fi/candidateApp.common.json
@@ -2,8 +2,6 @@
"contactSupport": "Ota yhteys tukeen",
"continueFilling": "Jatka täyttämistä",
"editingNotAllowed": "Vastausten muokkaaminen ei ole enää mahdollista, mutta voit silti tarkastella niitä. Ota yhteyttä tukeen, jos tiedoissa on virheitä.",
- "email": "Sähköposti",
- "emailPlaceholder": "Sähköposti",
"fullyCompleted": "Onneksi olkoon! Olet täyttänyt kaikki vaalikoneeseen tarvittavat tiedot.",
"greeting": "Hei, {username}!",
"home": "Alku",
diff --git a/frontend/src/lib/i18n/translations/fi/candidateApp.error.json b/frontend/src/lib/i18n/translations/fi/candidateApp.error.json
index 28e94575b..998dbb1f0 100644
--- a/frontend/src/lib/i18n/translations/fi/candidateApp.error.json
+++ b/frontend/src/lib/i18n/translations/fi/candidateApp.error.json
@@ -1,6 +1,5 @@
{
"candidateNoNomination": "Ehdokkaalta puuttuu vaalipiiri. Ota yhteyttä tukeen.",
- "loginFailed": "Kirjautuminen epäonnistui. Tarkista käyttäjätunnus ja salasana.",
"nominationNoElection": "Ehdokkaalta puuttuu vaali. Ota yhteyttä tukeen.",
"saveFailed": "Anteeksi! Vastaustesi tallentaminen ei onnistunut. Yritä uudelleen ja jos ongelma jatkuu, ota yhteyttä tukeen.",
"userNoCandidate": "Sisäänkirjautuneeseen käyttäjään ei ole yhdistetty ehdokastietoja. Ota yhteyttä tukeen.",
diff --git a/frontend/src/lib/i18n/translations/fi/candidateApp.login.json b/frontend/src/lib/i18n/translations/fi/candidateApp.login.json
index f4fc202f4..f4ff2b546 100644
--- a/frontend/src/lib/i18n/translations/fi/candidateApp.login.json
+++ b/frontend/src/lib/i18n/translations/fi/candidateApp.login.json
@@ -3,6 +3,5 @@
"forgotPassword": "Unohditko salasanan?",
"title": "Kirjaudu",
"unknownError": "Käyttäjätietojesi haku ei onnistunut. Ole hyvä ja ota yhteyttä tukeen, jos virhe toistuu.",
- "wrongEmailOrPassword": "Väärä sähköposti tai salasana",
"answersLockedInfo": "Rekisteröityminen tai vastausten muokkaaminen ei enää ole mahdollista, mutta voit silti kirjautua sisään nähdäksesi omat tietosi."
}
diff --git a/frontend/src/lib/i18n/translations/fi/common.json b/frontend/src/lib/i18n/translations/fi/common.json
index 413254b5e..b44478fcf 100644
--- a/frontend/src/lib/i18n/translations/fi/common.json
+++ b/frontend/src/lib/i18n/translations/fi/common.json
@@ -29,6 +29,8 @@
"faction": "Vaalisymboli",
"organization": "Vaalisymboli"
},
+ "email": "Sähköposti",
+ "emailPlaceholder": "Sähköposti",
"expandOrCollapse": "Laajenna tai pienennä tämä sisältö",
"faction": {
"plural": "ryhmittymät",
diff --git a/frontend/src/lib/i18n/translations/fi/error.json b/frontend/src/lib/i18n/translations/fi/error.json
index 54815e1d7..1e3cdabc3 100644
--- a/frontend/src/lib/i18n/translations/fi/error.json
+++ b/frontend/src/lib/i18n/translations/fi/error.json
@@ -5,7 +5,9 @@
"content": "
Olemme todella pahoillamme harmista. Voit koettaa palata takaisin ja ladata sivun uudelleen ja katsoa, toistuuko ongelma.
Jos tilanne ei korjaannu, pyydämme ystävällisesti lähettämään sähköpostia osoitteeseen {adminEmailLink} ja kuvailemaan mahdollisimman tarkasti, mitä tapahtui. Kiitos etukäteen!
",
"default": "Jotakin meni pieleen, anteeksi!",
"general": "Jotakin meni pieleen, anteeksi",
+ "loginFailed": "Kirjautuminen epäonnistui. Tarkista käyttäjätunnus ja salasana.",
"noNominations": "Vaalipiirissäsi ei ole vaalikoneeseen vastanneita ehdokkaita tai puolueita",
"noQuestions": "Vaalikoneessa ei vielä ole vaalipiiriäsi koskevia kysymyksiä",
- "unsupportedQuestion": "Tätä kysymystä ei voida näyttää, anteeksi!"
+ "unsupportedQuestion": "Tätä kysymystä ei voida näyttää, anteeksi!",
+ "wrongEmailOrPassword": "Väärä sähköposti tai salasana"
}
diff --git a/frontend/src/lib/i18n/translations/index.ts b/frontend/src/lib/i18n/translations/index.ts
index 1d622492f..f62e070be 100644
--- a/frontend/src/lib/i18n/translations/index.ts
+++ b/frontend/src/lib/i18n/translations/index.ts
@@ -7,6 +7,13 @@ export * from './translations.type';
*/
export const keys = [
'about',
+ 'adminApp.argumentCondensation',
+ 'adminApp.common',
+ 'adminApp.error',
+ 'adminApp.factorAnalysis',
+ 'adminApp.login',
+ 'adminApp.notSupported',
+ 'adminApp.questionInfo',
'candidateApp.basicInfo',
'candidateApp.common',
'candidateApp.error',
diff --git a/frontend/src/lib/i18n/translations/sv/adminApp.argumentCondensation.json b/frontend/src/lib/i18n/translations/sv/adminApp.argumentCondensation.json
new file mode 100644
index 000000000..21f095a1b
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/sv/adminApp.argumentCondensation.json
@@ -0,0 +1,4 @@
+{
+ "title": "Argumentkondensation",
+ "description": "Kondensera och hantera argument för valfrågor."
+}
diff --git a/frontend/src/lib/i18n/translations/sv/adminApp.common.json b/frontend/src/lib/i18n/translations/sv/adminApp.common.json
new file mode 100644
index 000000000..c657dbddd
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/sv/adminApp.common.json
@@ -0,0 +1,8 @@
+{
+ "home": "Hemsida",
+ "description": "Använd verktygen nedan för att hantera valrådgivningsapplikationen.",
+ "notAccessible": {
+ "content": "Åtkomst till administrationsvyn är för närvarande inaktiverad.",
+ "title": "Förlåt, administrationsvyn är inte tillgänglig"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/sv/adminApp.error.json b/frontend/src/lib/i18n/translations/sv/adminApp.error.json
new file mode 100644
index 000000000..f1d4cc76b
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/sv/adminApp.error.json
@@ -0,0 +1,5 @@
+{
+ "unauthorized": "Du har inte behörighet att komma åt detta område",
+ "unauthorizedDescription": "Ditt konto har autentiserats, men du har inte de nödvändiga administratörsbehörigheterna.",
+ "unauthorizedContact": "Detta avsnitt är reserverat endast för systemadministratörer. Om du tror att du borde ha tillgång, vänligen kontakta systemadministratören."
+}
diff --git a/frontend/src/lib/i18n/translations/sv/adminApp.factorAnalysis.json b/frontend/src/lib/i18n/translations/sv/adminApp.factorAnalysis.json
new file mode 100644
index 000000000..c392cc25d
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/sv/adminApp.factorAnalysis.json
@@ -0,0 +1,20 @@
+{
+ "title": "Faktoranalys",
+ "description": "Analysera och hantera faktorer för valfrågorna.",
+ "compute": {
+ "title": "Beräkna faktorer",
+ "description": "Beräkna latenta faktorer från svaren som kandidater och partier gett.",
+ "selectElections": "Välj valomgångar för vilka du vill beräkna faktorer.",
+ "button": "Beräkna faktorer",
+ "buttonLoading": "Beräknar faktorer...",
+ "mayTakeTime": "Detta kan ta en stund.",
+ "noElections": "Inga val tillgängliga",
+ "candidates": "kandidater",
+ "parties": {
+ "some": "och {count} partier",
+ "none": "och inga partier"
+ },
+ "haveAnswered": "har svarat",
+ "error": "Misslyckades med att beräkna faktorer"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/sv/adminApp.login.json b/frontend/src/lib/i18n/translations/sv/adminApp.login.json
new file mode 100644
index 000000000..b0e96582d
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/sv/adminApp.login.json
@@ -0,0 +1,17 @@
+{
+ "title": "Administratörsinloggning",
+ "appTitle": "Valkompass",
+ "appSubtitle": "Administration",
+ "instructions": "Ange dina inloggningsuppgifter.",
+ "email": "E-post",
+ "password": "Lösenord",
+ "button": "Logga in",
+ "forgotPassword": "Glömt lösenordet?",
+ "errors": {
+ "invalidCredentials": "Ogiltig e-post eller lösenord",
+ "genericError": "Ett fel uppstod vid inloggningsförsöket. Försök igen.",
+ "unauthorized": "Du behöver administratörsbehörighet för att få tillgång till denna sektion",
+ "sessionExpired": "Din session har upphört, vänligen logga in igen",
+ "unknown": "Ett okänt fel uppstod"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/sv/adminApp.notSupported.json b/frontend/src/lib/i18n/translations/sv/adminApp.notSupported.json
new file mode 100644
index 000000000..9d019f30b
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/sv/adminApp.notSupported.json
@@ -0,0 +1,5 @@
+{
+ "title": "Administrationsapplikationen stöds inte",
+ "content": "Administrationsapplikationen stöds inte med den aktuella dataadaptern.",
+ "heroEmoji": "🔧"
+}
diff --git a/frontend/src/lib/i18n/translations/sv/adminApp.questionInfo.json b/frontend/src/lib/i18n/translations/sv/adminApp.questionInfo.json
new file mode 100644
index 000000000..0b93806b8
--- /dev/null
+++ b/frontend/src/lib/i18n/translations/sv/adminApp.questionInfo.json
@@ -0,0 +1,25 @@
+{
+ "title": "Frågeinfogenerering",
+ "description": "Generera och hantera information för valfrågor.",
+ "pageDescription": "Generera eller redigera bakgrundsinformation för frågor.",
+ "generate": {
+ "title": "Generera info",
+ "description": "Generera information som skriver över tidigare genererad information.",
+ "questionType": "Välj frågor att generera information för",
+ "allQuestions": "Alla frågor",
+ "selectedQuestions": "Valda frågor",
+ "selectButton": "Välj...",
+ "button": "Generera information",
+ "buttonLoading": "Genererar information...",
+ "mayTakeTime": "Detta kan ta en stund.",
+ "error": "Misslyckades med att generera information",
+ "noQuestionSelected": "Välj minst en fråga"
+ },
+ "edit": {
+ "title": "Redigera information",
+ "description": "Redigera befintlig information. Du kan antingen redigera den direkt med en JSON-editor eller ladda ner den som CSV och sedan ladda upp den redigerade informationen.",
+ "editButton": "Redigera informationen",
+ "downloadButton": "Ladda ner informationen som CSV",
+ "uploadButton": "Ladda upp den redigerade informationen som CSV"
+ }
+}
diff --git a/frontend/src/lib/i18n/translations/sv/candidateApp.common.json b/frontend/src/lib/i18n/translations/sv/candidateApp.common.json
index ded81d745..630ec4dd5 100644
--- a/frontend/src/lib/i18n/translations/sv/candidateApp.common.json
+++ b/frontend/src/lib/i18n/translations/sv/candidateApp.common.json
@@ -2,8 +2,6 @@
"contactSupport": "Kontakta support",
"continueFilling": "Fortsätt fylla i",
"editingNotAllowed": "Redigering av dina svar är inte längre möjlig, men du kan fortfarande se dem. Kontakta support om det finns fel i uppgifterna.",
- "email": "E-post",
- "emailPlaceholder": "E-post",
"fullyCompleted": "Grattis! Du har fyllt i all information som behövs för valkompassen.",
"greeting": "Hej, {username}!",
"home": "Starta",
diff --git a/frontend/src/lib/i18n/translations/sv/candidateApp.error.json b/frontend/src/lib/i18n/translations/sv/candidateApp.error.json
index af02ec3fc..2d539cda6 100644
--- a/frontend/src/lib/i18n/translations/sv/candidateApp.error.json
+++ b/frontend/src/lib/i18n/translations/sv/candidateApp.error.json
@@ -1,6 +1,5 @@
{
"candidateNoNomination": "Kandidaten som är kopplad till den inloggade användaren har ingen nominering. Kontakta supporten.",
- "loginFailed": "Inloggningen misslyckades. Kontrollera ditt användarnamn och lösenord.",
"nominationNoElection": "Nomineringen som är kopplad till kandidaten har inget val. Kontakta supporten.",
"saveFailed": "Vi beklagar, det uppstod ett problem med att spara dina svar. Försök igen och om problemet kvarstår, kontakta supporten.",
"userNoCandidate": "Den inloggade användaren har ingen kandidatinformation kopplad till sig. Kontakta supporten.",
diff --git a/frontend/src/lib/i18n/translations/sv/candidateApp.login.json b/frontend/src/lib/i18n/translations/sv/candidateApp.login.json
index 12873c241..867013c3f 100644
--- a/frontend/src/lib/i18n/translations/sv/candidateApp.login.json
+++ b/frontend/src/lib/i18n/translations/sv/candidateApp.login.json
@@ -3,6 +3,5 @@
"forgotPassword": "Glömt lösenord?",
"title": "Registrera dig",
"unknownError": "Det uppstod ett fel med dina användardata. Kontakta supporten om problemet kvarstår.",
- "wrongEmailOrPassword": "Fel e-post eller lösenord",
"answersLockedInfo": "Registrering eller redigering av svar är inte längre möjligt, men du kan fortfarande logga in för att se din information."
}
diff --git a/frontend/src/lib/i18n/translations/sv/common.json b/frontend/src/lib/i18n/translations/sv/common.json
index 998afe235..6ee47e8af 100644
--- a/frontend/src/lib/i18n/translations/sv/common.json
+++ b/frontend/src/lib/i18n/translations/sv/common.json
@@ -29,6 +29,8 @@
"faction": "Valsymbol",
"organization": "Valsymbol"
},
+ "email": "E-post",
+ "emailPlaceholder": "E-post",
"expandOrCollapse": "Expandera eller kollapsa detta avsnitt",
"faction": {
"plural": "fraktioner",
diff --git a/frontend/src/lib/i18n/translations/sv/error.json b/frontend/src/lib/i18n/translations/sv/error.json
index e8bd3f5da..89a0bcdd2 100644
--- a/frontend/src/lib/i18n/translations/sv/error.json
+++ b/frontend/src/lib/i18n/translations/sv/error.json
@@ -5,7 +5,9 @@
"content": "
Vi är mycket ledsna för besväret. Du kan försöka gå tillbaka och ladda om sidan och se om problemet kvarstår.
Om du fortfarande upplever problemet, vänligen skicka ett e-postmeddelande till {adminEmailLink} och beskriv problemet. Tack så mycket!
",
"default": "Något gick fel, förlåt!",
"general": "Något gick fel",
+ "loginFailed": "Inloggningen misslyckades. Kontrollera ditt användarnamn och lösenord.",
"noNominations": "Det finns inga kandidater eller partier i din valkrets som har svarat på valkompassen",
"noQuestions": "Det finns ännu inga frågor i valkompassen som gäller din valkrets",
- "unsupportedQuestion": "Ursäkta, denna frågan kan inte visas!"
+ "unsupportedQuestion": "Ursäkta, denna frågan kan inte visas!",
+ "wrongEmailOrPassword": "Fel e-post eller lösenord"
}
diff --git a/frontend/src/lib/server/llm/llmProvider.ts b/frontend/src/lib/server/llm/llmProvider.ts
new file mode 100644
index 000000000..8bad61efa
--- /dev/null
+++ b/frontend/src/lib/server/llm/llmProvider.ts
@@ -0,0 +1,13 @@
+import { type LLMProvider, OpenAIProvider } from '@openvaa/llm';
+import { constants } from '$lib/server/constants';
+
+/**
+ * Get an LLMProvider instance based on env settings.
+ */
+export function getLLMProvider(): LLMProvider {
+ return new OpenAIProvider({
+ model: 'gpt-4o',
+ apiKey: constants.OPENAI_API_KEY,
+ maxContextTokens: 4096
+ });
+}
diff --git a/frontend/src/lib/types/generated/translationKey.ts b/frontend/src/lib/types/generated/translationKey.ts
index efa4c20f1..85b23ebf7 100644
--- a/frontend/src/lib/types/generated/translationKey.ts
+++ b/frontend/src/lib/types/generated/translationKey.ts
@@ -9,6 +9,64 @@ export type TranslationKey =
| 'about.source.sitename'
| 'about.source.title'
| 'about.title'
+ | 'adminApp.argumentCondensation.description'
+ | 'adminApp.argumentCondensation.title'
+ | 'adminApp.common.description'
+ | 'adminApp.common.home'
+ | 'adminApp.common.notAccessible.content'
+ | 'adminApp.common.notAccessible.title'
+ | 'adminApp.error.unauthorized'
+ | 'adminApp.error.unauthorizedContact'
+ | 'adminApp.error.unauthorizedDescription'
+ | 'adminApp.factorAnalysis.compute.button'
+ | 'adminApp.factorAnalysis.compute.buttonLoading'
+ | 'adminApp.factorAnalysis.compute.candidates'
+ | 'adminApp.factorAnalysis.compute.description'
+ | 'adminApp.factorAnalysis.compute.error'
+ | 'adminApp.factorAnalysis.compute.haveAnswered'
+ | 'adminApp.factorAnalysis.compute.mayTakeTime'
+ | 'adminApp.factorAnalysis.compute.noElections'
+ | 'adminApp.factorAnalysis.compute.parties.none'
+ | 'adminApp.factorAnalysis.compute.parties.some'
+ | 'adminApp.factorAnalysis.compute.selectElections'
+ | 'adminApp.factorAnalysis.compute.title'
+ | 'adminApp.factorAnalysis.description'
+ | 'adminApp.factorAnalysis.title'
+ | 'adminApp.login.appSubtitle'
+ | 'adminApp.login.appTitle'
+ | 'adminApp.login.button'
+ | 'adminApp.login.email'
+ | 'adminApp.login.errors.genericError'
+ | 'adminApp.login.errors.invalidCredentials'
+ | 'adminApp.login.errors.sessionExpired'
+ | 'adminApp.login.errors.unauthorized'
+ | 'adminApp.login.errors.unknown'
+ | 'adminApp.login.forgotPassword'
+ | 'adminApp.login.instructions'
+ | 'adminApp.login.password'
+ | 'adminApp.login.title'
+ | 'adminApp.notSupported.content'
+ | 'adminApp.notSupported.heroEmoji'
+ | 'adminApp.notSupported.title'
+ | 'adminApp.questionInfo.description'
+ | 'adminApp.questionInfo.edit.description'
+ | 'adminApp.questionInfo.edit.downloadButton'
+ | 'adminApp.questionInfo.edit.editButton'
+ | 'adminApp.questionInfo.edit.title'
+ | 'adminApp.questionInfo.edit.uploadButton'
+ | 'adminApp.questionInfo.generate.allQuestions'
+ | 'adminApp.questionInfo.generate.button'
+ | 'adminApp.questionInfo.generate.buttonLoading'
+ | 'adminApp.questionInfo.generate.description'
+ | 'adminApp.questionInfo.generate.error'
+ | 'adminApp.questionInfo.generate.mayTakeTime'
+ | 'adminApp.questionInfo.generate.noQuestionSelected'
+ | 'adminApp.questionInfo.generate.questionType'
+ | 'adminApp.questionInfo.generate.selectButton'
+ | 'adminApp.questionInfo.generate.selectedQuestions'
+ | 'adminApp.questionInfo.generate.title'
+ | 'adminApp.questionInfo.pageDescription'
+ | 'adminApp.questionInfo.title'
| 'candidateApp.basicInfo.editableInfos.description'
| 'candidateApp.basicInfo.editableInfos.title'
| 'candidateApp.basicInfo.error.invalidQuestion'
@@ -20,8 +78,6 @@ export type TranslationKey =
| 'candidateApp.common.contactSupport'
| 'candidateApp.common.continueFilling'
| 'candidateApp.common.editingNotAllowed'
- | 'candidateApp.common.email'
- | 'candidateApp.common.emailPlaceholder'
| 'candidateApp.common.fullyCompleted'
| 'candidateApp.common.greeting'
| 'candidateApp.common.home'
@@ -29,7 +85,6 @@ export type TranslationKey =
| 'candidateApp.common.voterApp'
| 'candidateApp.common.willBeHiddenIfMissing'
| 'candidateApp.error.candidateNoNomination'
- | 'candidateApp.error.loginFailed'
| 'candidateApp.error.nominationNoElection'
| 'candidateApp.error.registrationLocked'
| 'candidateApp.error.saveFailed'
@@ -55,7 +110,6 @@ export type TranslationKey =
| 'candidateApp.login.forgotPassword'
| 'candidateApp.login.title'
| 'candidateApp.login.unknownError'
- | 'candidateApp.login.wrongEmailOrPassword'
| 'candidateApp.logoutModal.continue'
| 'candidateApp.logoutModal.ingress'
| 'candidateApp.logoutModal.itemsLeft'
@@ -195,6 +249,8 @@ export type TranslationKey =
| 'common.electionSymbol.candidate'
| 'common.electionSymbol.faction'
| 'common.electionSymbol.organization'
+ | 'common.email'
+ | 'common.emailPlaceholder'
| 'common.expandOrCollapse'
| 'common.faction.plural'
| 'common.faction.singular'
@@ -387,9 +443,11 @@ export type TranslationKey =
| 'error.content'
| 'error.default'
| 'error.general'
+ | 'error.loginFailed'
| 'error.noNominations'
| 'error.noQuestions'
| 'error.unsupportedQuestion'
+ | 'error.wrongEmailOrPassword'
| 'feedback.description.label'
| 'feedback.description.placeholder'
| 'feedback.emailIntro'
diff --git a/frontend/src/lib/utils/route/route.ts b/frontend/src/lib/utils/route/route.ts
index 1d99b998c..ed8b4e46c 100644
--- a/frontend/src/lib/utils/route/route.ts
+++ b/frontend/src/lib/utils/route/route.ts
@@ -2,6 +2,8 @@ const CANDIDATE = '/[[lang=locale]]/candidate';
const CANDIDATE_PROT = `${CANDIDATE}/(protected)`;
const VOTER = '/[[lang=locale]]/(voters)';
const VOTER_LOCATED = `${VOTER}/(located)`;
+const ADMIN = '/[[lang=locale]]/admin';
+const ADMIN_PROT = `${ADMIN}/(protected)`;
/**
* Available routes and their ids.
@@ -48,7 +50,14 @@ export const ROUTE = {
CandAppSetPassword: `${CANDIDATE}/register/password`,
/** NB! If this route is changed, make sure to update the Strapi config at backend/vaa-strapi/src/extensions/users-permissions/strapi-server.js */
CandAppResetPassword: `${CANDIDATE}/password-reset`,
- CandAppSettings: `${CANDIDATE_PROT}/settings`
+ CandAppSettings: `${CANDIDATE_PROT}/settings`,
+
+ // Admin App
+ AdminAppHome: ADMIN,
+ AdminAppFactorAnalysis: `${ADMIN_PROT}/factor-analysis`,
+ AdminAppQuestionInfo: `${ADMIN_PROT}/question-info`,
+ AdminAppArgumentCondensation: `${ADMIN_PROT}/argument-condensation`,
+ AdminAppLogin: `${ADMIN}/login`
} as const;
/**
diff --git a/frontend/src/routes/[[lang=locale]]/Banner.svelte b/frontend/src/routes/[[lang=locale]]/Banner.svelte
index af0bb5367..0b87b03fe 100644
--- a/frontend/src/routes/[[lang=locale]]/Banner.svelte
+++ b/frontend/src/routes/[[lang=locale]]/Banner.svelte
@@ -17,7 +17,8 @@ Accesses `AppContext` and optionally `VoterContext`.
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
- import { LogoutButton } from '$candidate/components/logoutButton';
+ import { LogoutButton as CandidateLogoutButton } from '$candidate/components/logoutButton';
+ import { LogoutButton as AdminLogoutButton } from '$lib/dynamic-components/logoutButton';
import { Button } from '$lib/components/button';
import { getAppContext } from '$lib/contexts/app';
import { getLayoutContext } from '$lib/contexts/layout';
@@ -47,8 +48,12 @@ Accesses `AppContext` and optionally `VoterContext`.
text={$videoMode === 'video' ? $t('components.video.showTranscript') : $t('components.video.showVideo')} />
{/if}
- {#if $topBarSettings.actions.logout == 'show' && $appType === 'candidate' && $page.data.token}
-
+ {#if $topBarSettings.actions.logout == 'show' && $page.data.token}
+ {#if $appType === 'candidate'}
+
+ {:else if $appType === 'admin'}
+
+ {/if}
{/if}
{#if $topBarSettings.actions.feedback === 'show'}
diff --git a/frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.svelte b/frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.svelte
new file mode 100644
index 000000000..999ff22a0
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+{#if error}
+
+{:else if !ready}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.ts b/frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.ts
new file mode 100644
index 000000000..576507114
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/(protected)/+layout.ts
@@ -0,0 +1,69 @@
+/**
+ * Load the data for a logged-in admin user.
+ * - Verify user is authenticated
+ * - The admin role check is primarily done in the login handler
+ */
+
+import { redirect } from '@sveltejs/kit';
+import { dataWriter as dataWriterPromise } from '$lib/api/dataWriter';
+import { logDebugError } from '$lib/utils/logger';
+import { buildRoute } from '$lib/utils/route';
+import type { LoginError } from '$lib/admin/utils/loginError';
+
+export async function load({ fetch, parent, params: { lang } }) {
+ // Init dataWriter
+ const dataWriter = await dataWriterPromise;
+ dataWriter.init({ fetch });
+
+ // Get authToken
+ const authToken = (await parent()).token;
+ if (!authToken) {
+ // Not authenticated - redirect to login
+ return redirect(
+ 307,
+ buildRoute({
+ route: 'AdminAppLogin',
+ lang,
+ errorMessage: 'loginFailed'
+ })
+ );
+ }
+
+ // Get user data - just to confirm authentication is valid
+ const userData = await dataWriter.getBasicUserData({ authToken }).catch((e) => {
+ logDebugError(`Error fetching user data: ${e?.message ?? 'No error message'}`);
+ return undefined;
+ });
+ if (!userData) return await handleError('loginFailed');
+
+ // Check that the data is valid and the user is a candidate
+ const { role } = userData;
+ if (role !== 'admin') return await handleError('userNotAuthorized');
+
+ // Verify user has admin role
+ if (userData.role !== 'admin') {
+ logDebugError(
+ `[Admin App protected layout] Non-admin user attempted to access protected route: ${userData.username}`
+ );
+ return await handleError('userNotAuthorized');
+ }
+
+ return { userData };
+
+ /**
+ * Call logout and redirect to the login page with an error message.
+ */
+ async function handleError(error: LoginError): Promise {
+ await dataWriter
+ .logout({ authToken: authToken ?? '' })
+ .catch((e) => logDebugError(`[Admin App protected layout] Error logging out: ${e?.message ?? '-'}`));
+ redirect(
+ 307,
+ buildRoute({
+ route: 'AdminAppLogin',
+ lang,
+ errorMessage: error
+ })
+ );
+ }
+}
diff --git a/frontend/src/routes/[[lang=locale]]/admin/(protected)/+page.svelte b/frontend/src/routes/[[lang=locale]]/admin/(protected)/+page.svelte
new file mode 100644
index 000000000..8be792958
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/(protected)/+page.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+
{$t('adminApp.common.description')}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.svelte b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.svelte
new file mode 100644
index 000000000..399033b1b
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+{#if error}
+
+{:else if !ready}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.ts b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.ts
new file mode 100644
index 000000000..7d49a57b4
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+layout.ts
@@ -0,0 +1,18 @@
+/**
+ * Load the data for a argument condensation.
+ */
+import { dataProvider as dataProviderPromise } from '$lib/api/dataProvider';
+
+export async function load({ fetch, params: { lang } }) {
+ // Get question data
+ const dataProvider = await dataProviderPromise;
+ dataProvider.init({ fetch });
+
+ return {
+ questionData: dataProvider
+ .getQuestionData({
+ locale: lang
+ })
+ .catch((e) => e)
+ };
+}
diff --git a/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.server.ts b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.server.ts
new file mode 100644
index 000000000..bf0499a2b
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.server.ts
@@ -0,0 +1,96 @@
+import { DataRoot } from '@openvaa/data';
+import { type Actions, fail } from '@sveltejs/kit';
+import { dataProvider as dataProviderPromise } from '$lib/api/dataProvider';
+import { isValidResult } from '$lib/api/utils/isValidResult';
+import { getLLMProvider } from '$lib/server/llm/llmProvider';
+import type { Id } from '@openvaa/core';
+import type { DataApiActionResult } from '$lib/api/base/actionResult.type';
+import type { DPDataType } from '$lib/api/base/dataTypes';
+
+export const actions = {
+ default: async ({ fetch, request, cookies, params: { lang } }) => {
+ try {
+ const formData = await request.formData();
+ const electionId = formData.get('electionId');
+ const questionIds = formData.getAll('questionIds').map((id) => id.toString());
+
+ console.info('got', { electionId, questionIds });
+
+ // TODO: Check inputs here
+
+ // Call the generation function here
+ const result = await condenseArguments({ electionId, questionIds, fetch, locale: lang });
+
+ // When writing stuff to the backend, you'll need this
+ // const authToken = cookies.get('token');
+
+ return result
+ ? {
+ type: 'success'
+ }
+ : fail(500);
+ } catch (err) {
+ console.error('Error processing form:', err);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ return fail(500, { type: 'error', error: `Failed to process form: ${errorMessage}` });
+ }
+ }
+} satisfies Actions;
+
+// Preferably place this somewhere else, though, so that we can call it from other sources as well
+async function condenseArguments({
+ electionId,
+ questionIds,
+ fetch,
+ locale
+}: {
+ electionId: Id;
+ questionIds: Array;
+ authToken: string;
+ fetch: Fetch;
+ locale: string;
+}): Promise {
+ // Get data
+ // TODO: Consider wrapping this in a reusable function `getFullElection` or smth. which the other admin functions can also use
+ const dataRoot = new DataRoot();
+ const dataProvider = await dataProviderPromise;
+ dataProvider.init({ fetch });
+
+ const [electionData, constituencyData, questionData, nominationData] = (await Promise.all([
+ dataProvider.getElectionData({ locale }).catch((e) => e),
+ dataProvider.getConstituencyData({ locale }).catch((e) => e),
+ dataProvider
+ .getQuestionData({
+ electionId,
+ locale
+ })
+ .catch((e) => e),
+ dataProvider
+ .getNominationData({
+ electionId,
+ locale
+ })
+ .catch((e) => e)
+ ])) as [DPDataType['elections'], DPDataType['constituencies'], DPDataType['questions'], DPDataType['nominations']];
+
+ if (!isValidResult(electionData)) throw new Error('Error loading constituency data');
+ if (!isValidResult(constituencyData, { allowEmpty: true })) throw new Error('Error loading constituency data');
+ if (!isValidResult(questionData, { allowEmpty: true })) throw new Error('Error loading question data');
+ if (!isValidResult(nominationData, { allowEmpty: true })) throw new Error('Error loading nomination data');
+ dataRoot.update(() => {
+ dataRoot.provideElectionData(electionData);
+ dataRoot.provideConstituencyData(constituencyData);
+ dataRoot.provideQuestionData(questionData);
+ dataRoot.provideEntityData(nominationData.entities);
+ dataRoot.provideNominationData(nominationData.nominations);
+ });
+
+ // Do the deed here
+ // You can now access all data via the dataRoot object
+
+ const llm = getLLMProvider();
+
+ return { type: 'success' };
+}
+
+type Fetch = typeof fetch;
diff --git a/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.svelte b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.svelte
new file mode 100644
index 000000000..1316e39b6
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/(protected)/argument-condensation/+page.svelte
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
{$t('adminApp.argumentCondensation.description')}
+
+
+
+
diff --git a/frontend/src/routes/[[lang=locale]]/admin/+layout.server.ts b/frontend/src/routes/[[lang=locale]]/admin/+layout.server.ts
new file mode 100644
index 000000000..2632a3bbb
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/+layout.server.ts
@@ -0,0 +1,10 @@
+/**
+ * # Admin App outermost server loader
+ *
+ * Gets the jwt auth token from the cookie and adds it to page data from which it will be picked up by the `AdminContext`.
+ */
+
+export async function load({ cookies }) {
+ const token = cookies.get('token');
+ return { token };
+}
diff --git a/frontend/src/routes/[[lang=locale]]/admin/+layout.svelte b/frontend/src/routes/[[lang=locale]]/admin/+layout.svelte
new file mode 100644
index 000000000..dc3ee4161
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/+layout.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+{#if !$appSettings.dataAdapter.supportsAdminApp}
+
+{:else if !$appSettings.access.adminApp}
+
+{:else}
+
+
+
+
+{/if}
diff --git a/frontend/src/routes/[[lang=locale]]/admin/login/+page.server.ts b/frontend/src/routes/[[lang=locale]]/admin/login/+page.server.ts
new file mode 100644
index 000000000..5a856d309
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/login/+page.server.ts
@@ -0,0 +1,39 @@
+/**
+ * # Admin App login server action
+ */
+
+import { fail, redirect } from '@sveltejs/kit';
+import { UNIVERSAL_API_ROUTES } from '$lib/api/base/universalApiRoutes.js';
+import { buildRoute } from '$lib/utils/route';
+import type { LoginParams, LoginResult } from '../../api/auth/login/+server';
+
+export const actions = {
+ default: async ({ request, fetch, locals }) => {
+ const data = await request.formData();
+ const username = data.get('email') as string;
+ const password = data.get('password') as string;
+ const redirectTo = data.get('redirectTo') as string;
+
+ const response = await fetch(UNIVERSAL_API_ROUTES.login, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ username, password, role: 'admin' } as LoginParams)
+ });
+
+ const result = (await response.json()) as LoginResult;
+
+ if (result.type !== 'success') return fail(result.status ?? 500);
+
+ return redirect(
+ 303,
+ redirectTo
+ ? `/${locals.currentLocale}/${redirectTo}`
+ : buildRoute({
+ route: 'AdminAppHome',
+ locale: locals.currentLocale
+ })
+ );
+ }
+};
diff --git a/frontend/src/routes/[[lang=locale]]/admin/login/+page.svelte b/frontend/src/routes/[[lang=locale]]/admin/login/+page.svelte
new file mode 100644
index 000000000..185934d13
--- /dev/null
+++ b/frontend/src/routes/[[lang=locale]]/admin/login/+page.svelte
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+