diff --git a/src/database/seed/data.ts b/src/database/seed/data.ts index 241b45afb..15c4606d7 100644 --- a/src/database/seed/data.ts +++ b/src/database/seed/data.ts @@ -218,7 +218,6 @@ export const models: SeedClientOptions["models"] = { }, doorAccessPolicy: { data: { - role: "dsek", startDatetime: () => faker.helpers.maybe(() => faker.date.past()) ?? null, endDatetime: (ctx) => ctx.data.startDatetime && Math.random() > 0.5 diff --git a/src/routes/(app)/admin/doors/+error.svelte b/src/routes/(app)/admin/doors/+error.svelte new file mode 100644 index 000000000..c2e95ea9e --- /dev/null +++ b/src/routes/(app)/admin/doors/+error.svelte @@ -0,0 +1,16 @@ + + + + +
+ +

+ {page.status}: {page.error?.message} +

+
+
+
diff --git a/src/routes/(app)/admin/doors/+page.server.ts b/src/routes/(app)/admin/doors/+layout.server.ts similarity index 63% rename from src/routes/(app)/admin/doors/+page.server.ts rename to src/routes/(app)/admin/doors/+layout.server.ts index 5b6457849..732f5677f 100644 --- a/src/routes/(app)/admin/doors/+page.server.ts +++ b/src/routes/(app)/admin/doors/+layout.server.ts @@ -1,13 +1,14 @@ import apiNames from "$lib/utils/apiNames"; import { authorize } from "$lib/utils/authorization"; -import type { PageServerLoad } from "./$types"; +import type { LayoutServerLoad } from "./$types"; -export const load: PageServerLoad = async ({ locals }) => { +export const load: LayoutServerLoad = async ({ locals, params }) => { const { prisma, user } = locals; authorize(apiNames.DOOR.READ, user); const doors = await prisma.door.findMany(); return { doors, + slug: params.slug, }; }; diff --git a/src/routes/(app)/admin/doors/+layout.svelte b/src/routes/(app)/admin/doors/+layout.svelte new file mode 100644 index 000000000..4ada03aa6 --- /dev/null +++ b/src/routes/(app)/admin/doors/+layout.svelte @@ -0,0 +1,47 @@ + + +
+

{m.doors()}

+ +
+ +
+ +
+ + +
+ {@render children()} +
+
+
diff --git a/src/routes/(app)/admin/doors/+page.svelte b/src/routes/(app)/admin/doors/+page.svelte index 0a8eebb03..5420067cd 100644 --- a/src/routes/(app)/admin/doors/+page.svelte +++ b/src/routes/(app)/admin/doors/+page.svelte @@ -1,5 +1,16 @@ - - + + +
+ +

+ {m.admin_doors_choose()} +

+
+
+
diff --git a/src/routes/(app)/admin/doors/edit/[slug]/+page.server.ts b/src/routes/(app)/admin/doors/edit/[slug]/+page.server.ts index 0fabe0e68..b05ddb1f9 100644 --- a/src/routes/(app)/admin/doors/edit/[slug]/+page.server.ts +++ b/src/routes/(app)/admin/doors/edit/[slug]/+page.server.ts @@ -1,63 +1,100 @@ import apiNames from "$lib/utils/apiNames"; import { z } from "zod"; import type { Actions, PageServerLoad } from "./$types"; -import { message, setError, superValidate } from "sveltekit-superforms/server"; +import { message, superValidate } from "sveltekit-superforms/server"; import { zod4 } from "sveltekit-superforms/adapters"; -import { fail } from "@sveltejs/kit"; +import { error, fail } from "@sveltejs/kit"; import { authorize } from "$lib/utils/authorization"; +import authorizedPrismaClient from "$lib/server/authorizedPrisma"; +import * as m from "$paraglide/messages"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +dayjs.extend(utc); +dayjs.extend(timezone); -export const load: PageServerLoad = async ({ locals, params }) => { +export const load: PageServerLoad = async ({ locals, params, parent }) => { const { prisma, user } = locals; authorize(apiNames.DOOR.READ, user); + const { doors } = await parent(); + const door = doors.find((door) => door.name === params.slug); + + if (!door) error(404, m.admin_doors_notFound()); + const doorAccessPolicies = await prisma.doorAccessPolicy.findMany({ where: { doorName: params.slug, - OR: [ - { - endDatetime: { - gte: new Date(), - }, - }, - { - endDatetime: null, - }, - ], - }, - include: { - member: true, + OR: [{ endDatetime: { gte: new Date() } }, { endDatetime: null }], }, - orderBy: [ - { - startDatetime: "asc", - }, - { - role: "asc", - }, - { - studentId: "asc", - }, - ], + include: { member: true }, + orderBy: [{ startDatetime: "asc" }, { role: "asc" }, { studentId: "asc" }], }); + return { + door, doorAccessPolicies, - createForm: await superValidate(zod4(createSchema), { id: "createForm" }), - banForm: await superValidate(zod4(createSchema), { id: "banForm" }), + createForm: await superValidate(zod4(createSchema)), deleteForm: await superValidate(zod4(deleteSchema)), }; }; const createSchema = z .object({ - studentId: z.string().min(1).optional(), - role: z.string().min(1).optional(), - startDatetime: z.date().optional(), - endDatetime: z.date().optional(), - information: z.string().optional(), + subject: z.string().min(1), + type: z.enum(["member", "role"]).default("member"), + mode: z.enum(["allow", "deny"]).default("allow"), + startDatetime: z.string().date().optional(), + endDatetime: z.string().date().optional(), + reason: z.string().optional(), }) - .refine((data) => data.studentId != null || data.role != null, { - message: "Du måste ange roll eller student id", - }); + // These refinements return true for valid data, but it's + // easier to express them in terms of what is invalid. + .refine( + // Require the start date to be before the end date + ({ startDatetime: start, endDatetime: end }) => + !(start && end && dayjs(end).isBefore(start)), + { message: m.admin_doors_endDateBeforeStart(), path: ["endDatetime"] }, + ) + .refine( + // Require an end date for member rules + (data) => !(data.type === "member" && !data.endDatetime), + { message: m.admin_doors_memberRuleRequireEnd(), path: ["endDatetime"] }, + ) + .refine( + // Require a reason for member rules + (data) => !(data.type === "member" && !data.reason), + { message: m.admin_doors_memberRuleRequireReason(), path: ["reason"] }, + ) + .refine( + // Require a reason for bans + (data) => !(data.mode === "deny" && !data.reason), + { message: m.admin_doors_banRuleRequireReason(), path: ["reason"] }, + ) + .refine( + // TODO: Banning groups is not implemented + (data) => !(data.type === "role" && data.mode === "deny"), + { message: "Not implemented", path: ["mode"] }, + ) + .refine( + async (data) => { + if (data.type === "member") { + // check if member exists + return await authorizedPrismaClient.member.findFirst({ + where: { studentId: data.subject }, + }); + } else { + // check if role exists + return ( + data.subject === "*" || + (await authorizedPrismaClient.position.findFirst({ + where: { id: { startsWith: `${data.subject}%` } }, + })) + ); + } + }, + { message: m.admin_doors_memberOrRoleNotFound(), path: ["subject"] }, + ); const deleteSchema = z.object({ id: z.string(), @@ -69,49 +106,28 @@ export const actions: Actions = { const form = await superValidate(request, zod4(createSchema)); if (!form.valid) return fail(400, { form }); const doorName = params.slug; - const { studentId } = form.data; - if ( - studentId && - (await prisma.member.count({ - where: { studentId }, - })) <= 0 - ) { - return setError(form, "studentId", "Medlemmen finns inte"); - } - await prisma.doorAccessPolicy.create({ - data: { - doorName, - ...form.data, - }, - }); - return message(form, { - message: "Dörrpolicy skapad", - type: "success", - }); - }, - ban: async ({ request, locals, params }) => { - const { prisma } = locals; - const form = await superValidate(request, zod4(createSchema)); - if (!form.valid) return fail(400, { form }); - const doorName = params.slug; - const { studentId } = form.data; - if ( - studentId && - (await prisma.member.count({ - where: { studentId }, - })) <= 0 - ) { - return setError(form, "studentId", "Medlemmen finns inte"); - } + const { mode, subject, type, startDatetime, endDatetime, reason } = + form.data; + await prisma.doorAccessPolicy.create({ data: { doorName, - isBan: true, - ...form.data, + startDatetime: dayjs(startDatetime) + .startOf("day") + .tz("Europe/Stockholm", true) + .toDate(), + endDatetime: dayjs(endDatetime) + .endOf("day") + .tz("Europe/Stockholm", true) + .toDate(), + isBan: mode === "deny", + information: reason, + ...(type === "member" ? { studentId: subject } : { role: subject }), }, }); + return message(form, { - message: "Dörrpolicy skapad", + message: m.admin_doors_ruleCreated(), type: "success", }); }, @@ -124,7 +140,7 @@ export const actions: Actions = { where: { id }, }); return message(form, { - message: "Dörrpolicy raderad", + message: m.admin_doors_ruleDeleted(), type: "success", }); }, diff --git a/src/routes/(app)/admin/doors/edit/[slug]/+page.svelte b/src/routes/(app)/admin/doors/edit/[slug]/+page.svelte index 0a8eebb03..7d2b34b4f 100644 --- a/src/routes/(app)/admin/doors/edit/[slug]/+page.svelte +++ b/src/routes/(app)/admin/doors/edit/[slug]/+page.svelte @@ -1,5 +1,283 @@ - - +
+ + + + {m.admin_doors_addAccessRule()} + + {m.admin_doors_grantOrRestrict({ door: door.verboseName })} + + + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+
+ + + + + {m.admin_doors_currentRules()} + + {#if policies.length === 0} + {m.admin_doors_noRules()} + {:else} + {m.admin_doors_numRules({ count: policies.length })} + {/if} + + + {#if policies.length > 0} + +
    + {#each policies as policy (policy.id)} + {@const type = policy.studentId === null ? "role" : "member"} +
  • +
    +
    +

    + {#if type === "member"} + {getFullName(policy.member!, { hideNickname: true })} + {:else} + {policy.role} + {/if} +

    + + {#if type === "member"} + {m.admin_doors_member()} + {:else} + {m.admin_doors_role()} + {/if} + + {#if policy.isBan} + {m.admin_doors_banned()} + {/if} + {#if policy.endDatetime} + + {m.admin_doors_expires()} + {policy.endDatetime.toLocaleDateString("sv")} + + {/if} +
    + {#if policy.information} +

    + {m.admin_doors_reason()}: {policy.information} +

    + {/if} +
    + + +
  • + {/each} +
+
+ {/if} +
+
+ + + + {#if selectedPolicy} + {@const subject = + selectedPolicy.role || + getFullName(selectedPolicy.member!, { hideNickname: true })} + + + {m.admin_doors_removeAccessRule()} + + + {@html m.admin_doors_areYouSure({ door: door.verboseName, subject })} + + + + {m.cancel()} +
{ + open = false; + }} + > + + {m.save()} +
+
+
+ {/if} +
diff --git a/src/translations/en.json b/src/translations/en.json index 4d11bff7a..df1def5e1 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -204,22 +204,38 @@ "admin_alerts_alertCreated": "Global alert created", "admin_alerts_alertRemoved": "Global alert removed", "admin_doors_door": "Door", + "admin_doors_choose": "Choose a door to manage access rules", "admin_doors_role": "Role", "admin_doors_member": "Member", - "admin_doors_roleMember": "Role/member", - "admin_doors_startDate": "Start date", - "admin_doors_endDate": "End date", + "admin_doors_allow": "Allow", + "admin_doors_deny": "Deny", + "admin_doors_type": "Type", + "admin_doors_mode": "Mode", + "admin_doors_addAccessRule": "Add access rule", + "admin_doors_removeAccessRule": "Remove access rule", + "admin_doors_grantOrRestrict": "Grant or restrict access to {door}", + "admin_doors_startDate": "Start date (optional)", + "admin_doors_endDate": "End date (optional)", + "admin_doors_reasonLabel": "Reason (optional)", + "admin_doors_reasonPlaceholder": "Why is this rule added?", + "admin_doors_add": "Add rule", "admin_doors_remove": "Remove", - "admin_doors_add": "Add", "admin_doors_edit": "Edit", - "admin_doors_info": "Additional information", - "admin_doors_revokeDoorAccess": "Revoke door access", - "admin_doors_grantDoorAccess": "Grant door access", - "admin_doors_startDateOptional": "Start date (optional)", - "admin_doors_endDateOptional": "End date (optional)", - "admin_doors_revokeAreYouSure": "Are you sure you want to revoke access to {door} for {target}?", - "admin_doors_revokeBanAreYouSure": "Are you sure you want to rescind the ban to {door} for {target}?", - "admin_doors_profileAvatar": "Profile avatar", + "admin_doors_currentRules": "Current access rules", + "admin_doors_noRules": "No access rules configured yet", + "admin_doors_numRules": "{count} rule(s) configured", + "admin_doors_banned": "Banned", + "admin_doors_expires": "Expires", + "admin_doors_reason": "Anledning", + "admin_doors_areYouSure": "Are you sure you want to remove the access rule for {subject} to {door}?", + "admin_doors_notFound": "Door does not exist", + "admin_doors_endDateBeforeStart": "End date cannot be before start date", + "admin_doors_memberOrRoleNotFound": "The member/role does not exist", + "admin_doors_ruleCreated": "Access rule created", + "admin_doors_ruleDeleted": "Access rule removed", + "admin_doors_memberRuleRequireEnd": "Access rules for members must have an end date", + "admin_doors_memberRuleRequireReason": "Access rules for members must have a reason", + "admin_doors_banRuleRequireReason": "Ban rules must have a reason", "admin_emailalias_add": "Create", "admin_emailalias_addAlias": "Create an email alias", "admin_emailalias_addAliasDescription": "Enter the email alias you'd like to create.", diff --git a/src/translations/sv.json b/src/translations/sv.json index 6b8409708..f53a6845e 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -204,22 +204,38 @@ "admin_alerts_alertCreated": "Globalt meddelande skapad", "admin_alerts_alertRemoved": "Globalt meddelande borttagen", "admin_doors_door": "Dörr", + "admin_doors_choose": "Välj en dörr för att hantera åtkomstregler", "admin_doors_role": "Roll", "admin_doors_member": "Medlem", - "admin_doors_roleMember": "Roll/medlem", - "admin_doors_startDate": "Startdatum", - "admin_doors_endDate": "Slutdatum", + "admin_doors_allow": "Tillåt", + "admin_doors_deny": "Neka", + "admin_doors_type": "Typ", + "admin_doors_mode": "Läge", + "admin_doors_addAccessRule": "Lägg till åtkomstregel", + "admin_doors_removeAccessRule": "Ta bort åtkomstregel", + "admin_doors_grantOrRestrict": "Ge eller neka åtkomst till {door}", + "admin_doors_startDate": "Startdatum (valfritt)", + "admin_doors_endDate": "Slutdatum (valfritt)", + "admin_doors_reasonLabel": "Anledning (valfritt)", + "admin_doors_reasonPlaceholder": "Varför läggs denna regel till?", + "admin_doors_add": "Lägg till regel", "admin_doors_remove": "Ta bort", - "admin_doors_add": "Lägg till", "admin_doors_edit": "Redigera", - "admin_doors_info": "Extra information", - "admin_doors_revokeDoorAccess": "Återkalla dörråtkomst", - "admin_doors_grantDoorAccess": "Ge dörråtkomst", - "admin_doors_startDateOptional": "Startdatum (frivilligt)", - "admin_doors_endDateOptional": "Slutdatum (frivilligt)", - "admin_doors_revokeAreYouSure": "Är du säker på att du vill återkalla åtkomst till {door} för {target}?", - "admin_doors_revokeBanAreYouSure": "Är du säker på att du vill ta bort spärren till {door} för {target}?", - "admin_doors_profileAvatar": "Profilavatar", + "admin_doors_currentRules": "Nuvarande åtkomstregler", + "admin_doors_noRules": "Inga åtkomstregler konfigurerade än", + "admin_doors_numRules": "{count} regel(er) konfigurerade", + "admin_doors_banned": "Spärrad", + "admin_doors_expires": "Upphör", + "admin_doors_reason": "Anledning", + "admin_doors_areYouSure": "Är du säher på att du vill ta bort åtkomstregeln för {subject} till {door}?", + "admin_doors_notFound": "Dörren finns ej", + "admin_doors_endDateBeforeStart": "Slutdatum kan inte vara före startdatum", + "admin_doors_memberOrRoleNotFound": "Medlemmen/rollen finns inte", + "admin_doors_ruleCreated": "Åtkomstregel skapad", + "admin_doors_ruleDeleted": "Åtkomstregel borttagen", + "admin_doors_memberRuleRequireEnd": "Regler för medlemmar måste ha ett slutdatum", + "admin_doors_memberRuleRequireReason": "Regler för medlemmar måste ha en anledning", + "admin_doors_banRuleRequireReason": "Spärrningsregler måste ha en anledning", "admin_emailalias_add": "Skapa", "admin_emailalias_addAlias": "Skapa ett nytt mejlalias", "admin_emailalias_addAliasDescription": "Fyll i mailaliaset du hade velat skapa.",