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/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')}

+ +
+
+
+ {$t('adminApp.argumentCondensation.generate.questionType')} +
+ {#each options as option} + + {/each} +
+
+ + {#if selectedOption === 'selectedQuestions'} + + {@const questions = $dataRoot.getQuestionsByType('opinion')} +
+ + {#each questions as question, i} + + {/each} +
+ {/if} +
+ + {#if status === 'error'} + + {:else if status === 'no-selections'} + + {:else if status === 'success'} + + {/if} + +
+
+ +
+
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 @@ + + + + + + +

{$t('adminApp.login.appTitle')}

+

{$t('adminApp.login.appSubtitle')}

+
+ +

{$t('adminApp.login.instructions')}

+ +
{ + status = 'loading'; + return async ({ update, result }) => { + await update(); + if (result.type === 'failure') { + status = 'error'; + errorMessage = + result.status === 400 + ? $t('error.wrongEmailOrPassword') + : result.status === 403 + ? $t('error.403') + : $t('error.general'); + return; + } + await applyAction(result); + status = 'success'; + }; + }}> +
+ + + +
+ +
+ {#if status === 'error'} + + {/if} +
+ +
+ {#if $appSettings.access.voterApp} + +
+
+ +