diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4c96f20..67f10de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -38,10 +38,10 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -56,10 +56,10 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -74,17 +74,17 @@ jobs: playwright: name: 🎭 Playwright - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 timeout-minutes: 60 steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 🏄 Copy test env vars run: cp .env.example .env - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -99,7 +99,7 @@ jobs: - name: 🏦 Cache Database id: db-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: prisma/data.db key: @@ -114,7 +114,7 @@ jobs: run: npx playwright test - name: 📊 Upload report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: playwright-report @@ -132,7 +132,7 @@ jobs: steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 👀 Read app name uses: SebRollen/toml-action@v1.0.2 diff --git a/app/components/EventAgenda.tsx b/app/components/EventAgenda.tsx index 175c4fa..d377f6d 100644 --- a/app/components/EventAgenda.tsx +++ b/app/components/EventAgenda.tsx @@ -1,34 +1,37 @@ import { useUser } from '~/utils/user.ts' -import { volunteerTypes, type EventWithVolunteers } from '~/data.ts' - -type VolunteerTypes = typeof volunteerTypes -type VolunteerType = VolunteerTypes[number] +import { + volunteerTypes, + getVolunteers, + getVolunteerReq, + type EventWithVolunteers, + type VolunteerTypeEntry, +} from '~/data.ts' interface PositionStatusProps { event: EventWithVolunteers - volunteerType: VolunteerType + volunteerType: VolunteerTypeEntry } function PositionStatus({ volunteerType, event }: PositionStatusProps) { const user = useUser() - const positionFilled = - event[volunteerType.field].length >= event[volunteerType.reqField] + const volunteers = getVolunteers(event, volunteerType.field) + const required = getVolunteerReq(event, volunteerType.reqField) + const positionFilled = volunteers.length >= required const containerClass = `grid grid-cols-2 gap-4 ${ positionFilled ? 'text-muted-foreground' : '' }` - const userIsRegistered = event[volunteerType.field] - .map(user => user.id) + const userIsRegistered = (volunteers as { id: string }[]) + .map(u => u.id) .includes(user.id) const volunteerTypeClass = `capitalize ${ userIsRegistered ? 'before:content-["✅"] before:pr-1' : '' }` - const spotsLeft = - event[volunteerType.reqField] - event[volunteerType.field].length + const spotsLeft = required - volunteers.length - if (event[volunteerType.reqField] > 0) + if (required > 0) return (
{volunteerType.displayName}
diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index e0492c4..a61155a 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -1,6 +1,6 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' import { Form, Link, NavLink } from '@remix-run/react' -import { Cat } from 'lucide-react' +import { Cat, Menu, X } from 'lucide-react' import { Icon } from '~/components/ui/icon.tsx' import { ThemeSwitch } from '~/routes/resources+/theme/index.tsx' import { useUser } from '~/utils/user.ts' @@ -14,6 +14,7 @@ export function Sidebar({ const user = useUser() const formRef = useRef(null) const userIsAdmin = user?.roles.find(r => r.name === 'admin') + const [mobileOpen, setMobileOpen] = useState(false) const navLinkClass = ({ isActive }: { isActive: boolean }) => `flex items-center gap-3 rounded-md px-3 py-2 text-body-sm font-medium transition-colors ${ @@ -22,18 +23,30 @@ export function Sidebar({ : 'text-sidebar-foreground hover:bg-sidebar-border' }` - return ( -
- + + ) + + return ( + <> + {/* Mobile hamburger button */} + + + {/* Mobile overlay */} + {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + {/* Mobile sidebar drawer */} + + + {/* Desktop sidebar (always visible) */} + + ) } diff --git a/app/data.ts b/app/data.ts index 67f238d..3a4f220 100644 --- a/app/data.ts +++ b/app/data.ts @@ -1,4 +1,5 @@ import { Prisma } from '@prisma/client' +import { getRoleLabels, getRoleDescriptions } from '~/utils/role-labels.ts' export const siteName = 'The Barn Volunteer Portal' export const siteEmailAddress = 'hello@thebarnaz.com' @@ -6,37 +7,58 @@ export const siteEmailAddressWithName = siteName + ' ' export const siteBaseUrl = 'https://thebarnaz.com' -export const volunteerTypes = [ - { - displayName: 'cleaning crew', - field: 'cleaningCrew', - reqField: 'cleaningCrewReq', - description: - 'Cleaning crew volunteers help maintain the facility, check waterers, sweep common areas, and handle other miscellaneous cleaning tasks. No prior experience with animals is required.', - }, - { - displayName: 'side walkers', - field: 'sideWalkers', - reqField: 'sideWalkersReq', - description: - 'Side walkers walk alongside participants helping to support them during sessions. No prior experience with animals needed. Must be able to walk on uneven surfaces.', - }, - { - displayName: 'lesson assistants', - field: 'lessonAssistants', - reqField: 'lessonAssistantsReq', - description: - 'Lesson assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.', - }, - { - displayName: 'animal handlers', - field: 'animalHandlers', - reqField: 'animalHandlersReq', - description: - 'Animal handlers guide and manage animals during sessions. Should have 1+ years of experience with animals, and must be able to walk on uneven surfaces.', - }, +/** Volunteer role field names — stable identifiers used in schema and forms */ +export const volunteerFields = [ + 'cleaningCrew', + 'sideWalkers', + 'lessonAssistants', + 'animalHandlers', ] as const +export type VolunteerField = (typeof volunteerFields)[number] + +export const volunteerReqFields = { + cleaningCrew: 'cleaningCrewReq', + sideWalkers: 'sideWalkersReq', + lessonAssistants: 'lessonAssistantsReq', + animalHandlers: 'animalHandlersReq', +} as const + +export type VolunteerReqField = + (typeof volunteerReqFields)[VolunteerField] + +/** + * Returns volunteer type metadata with display names and descriptions + * adapted to the organization's animal type. Falls back to generic labels + * when no animalType is provided. + */ +export function getVolunteerTypes(animalType?: string | null) { + const labels = getRoleLabels(animalType) + const descriptions = getRoleDescriptions(animalType) + + return volunteerFields.map(field => ({ + displayName: labels[field], + field, + reqField: volunteerReqFields[field], + description: descriptions[field], + })) +} + +/** Type-safe volunteer type entry for use in components that index into events */ +export interface VolunteerTypeEntry { + displayName: string + field: VolunteerField + reqField: VolunteerReqField + description: string +} + +/** + * Default volunteer types using horse-specific labels for backward + * compatibility with the existing equestrian customer. Use + * getVolunteerTypes(animalType) when org context is available. + */ +export const volunteerTypes: VolunteerTypeEntry[] = getVolunteerTypes() + export interface UserData { id: string name: string | null @@ -85,6 +107,20 @@ export interface CalEvent { sideWalkers: UserData[] } +/** Safely index into an event by volunteer field name */ +export function getVolunteers< + T extends Record, +>(event: T, field: VolunteerField): T[VolunteerField] { + return event[field] +} + +/** Safely index into an event by volunteer req field name */ +export function getVolunteerReq< + T extends Record, +>(event: T, reqField: VolunteerReqField): number { + return event[reqField] +} + const EventWithAllRelations = Prisma.validator()({ include: { animals: true, diff --git a/app/root.tsx b/app/root.tsx index 2123cfa..3fceaf2 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -156,7 +156,7 @@ function App() { // Authenticated: sidebar layout
-
+
diff --git a/app/routes/_marketing+/index.tsx b/app/routes/_marketing+/index.tsx index 7cf76ed..6de558a 100644 --- a/app/routes/_marketing+/index.tsx +++ b/app/routes/_marketing+/index.tsx @@ -50,12 +50,12 @@ export default function Index() {
{/* Hero */}
- + Volunteer Scheduling for Nonprofits

Coordinate volunteers.{' '} - Simplify scheduling. + Simplify scheduling.

The Barn gives your organization a dedicated space to manage events, @@ -63,12 +63,12 @@ export default function Index() {

{user ? ( - ) : ( <> - )}
+ + {/* Attribution */} +
) } diff --git a/app/routes/admin+/_users+/users.edit.$userId.tsx b/app/routes/admin+/_users+/users.edit.$userId.tsx index bcd6e8a..cdae2c8 100644 --- a/app/routes/admin+/_users+/users.edit.$userId.tsx +++ b/app/routes/admin+/_users+/users.edit.$userId.tsx @@ -79,8 +79,16 @@ export const loader = async ({ request, params }: DataFunctionArgs) => { export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) - await requireOrgMember(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') + + // Verify target user belongs to this org before mutating + const targetUser = await prisma.user.findFirst({ + where: { id: params.userId, orgId }, + }) + if (!targetUser) { + throw new Response('not found', { status: 404 }) + } const formData = await request.formData() const submission = await parse(formData, { async: true, diff --git a/app/routes/calendar+/$eventId.tsx b/app/routes/calendar+/$eventId.tsx index 440c996..c4df68d 100644 --- a/app/routes/calendar+/$eventId.tsx +++ b/app/routes/calendar+/$eventId.tsx @@ -13,7 +13,7 @@ import type { CalEvent, EventWithAllRelations, } from '~/data.ts' -import { volunteerTypes } from '~/data.ts' +import { volunteerTypes, getVolunteers, getVolunteerReq } from '~/data.ts' import { clsx } from 'clsx' import { useFetcher, Outlet } from '@remix-run/react' import { z } from 'zod' @@ -239,9 +239,9 @@ export function VolunteerSection({ event, }: volunteerSectionProps) { const idx = volunteerTypeIdx - const vts = volunteerTypes - const volunteers = event[vts[idx].field] - const volunteersRequired = event[vts[idx].reqField] + const vt = volunteerTypes[idx] + const volunteers = getVolunteers(event, vt.field) as UserData[] + const volunteersRequired = getVolunteerReq(event, vt.reqField) let unfilled = volunteersRequired - volunteers.length let placeholders = [] @@ -252,13 +252,13 @@ export function VolunteerSection({ return (
-

{vts[idx].displayName}

+

{vt.displayName}

{volunteers.length} registered of {volunteersRequired} required

- {event[volunteerTypes[idx].field].map(user => { + {volunteers.map(user => { return })} {placeholders} diff --git a/app/routes/calendar+/index.tsx b/app/routes/calendar+/index.tsx index cdf2944..70f748f 100644 --- a/app/routes/calendar+/index.tsx +++ b/app/routes/calendar+/index.tsx @@ -6,6 +6,8 @@ import { Icon } from '~/components/ui/icon.tsx' import { volunteerTypes, + getVolunteers, + getVolunteerReq, type UserData, type AnimalData, type EventWithVolunteers, @@ -517,13 +519,11 @@ function RegistrationDialogue({ selectedEventId, events }: RegistrationProps) { >
    {volunteerTypes.map(volunteerType => { - const spotsLeft = - calEvent[`${volunteerType.field}Req`] - - calEvent[volunteerType.field].length + const volunteers = getVolunteers(calEvent, volunteerType.field) + const required = getVolunteerReq(calEvent, volunteerType.reqField) + const spotsLeft = required - volunteers.length - const isFull = - calEvent[volunteerType.reqField] <= - calEvent[volunteerType.field].length + const isFull = required <= volunteers.length let hasPermissions = true if (volunteerType.field == 'lessonAssistants') { diff --git a/app/routes/resources+/theme/index.tsx b/app/routes/resources+/theme/index.tsx index 5ba1b83..a8ba00d 100644 --- a/app/routes/resources+/theme/index.tsx +++ b/app/routes/resources+/theme/index.tsx @@ -79,8 +79,9 @@ export function ThemeSwitch({ }, }) - const mode = userPreference ?? 'light' - const nextMode = mode === 'light' ? 'dark' : 'light' + const mode = userPreference ?? 'system' + const nextMode = + mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' const modeLabel = { light: ( @@ -92,6 +93,11 @@ export function ThemeSwitch({ Dark ), + system: ( + + System + + ), } return ( diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 95360ce..fd7681f 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -180,12 +180,18 @@ export async function verifyLogin( usernameOrEmail: string, password: Password['hash'], ) { - const userWithPassword = await prisma.user.findFirst({ - where: { - OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }], - }, + // Prioritize exact username match to avoid ambiguity when a username + // happens to equal another user's email address + let userWithPassword = await prisma.user.findUnique({ + where: { username: usernameOrEmail }, select: { id: true, password: { select: { hash: true } } }, }) + if (!userWithPassword) { + userWithPassword = await prisma.user.findUnique({ + where: { email: usernameOrEmail }, + select: { id: true, password: { select: { hash: true } } }, + }) + } if (!userWithPassword || !userWithPassword.password) { return null diff --git a/app/utils/role-labels.ts b/app/utils/role-labels.ts new file mode 100644 index 0000000..41a38b7 --- /dev/null +++ b/app/utils/role-labels.ts @@ -0,0 +1,97 @@ +/** + * Maps the internal volunteer slot field names to human-readable display names + * based on the organization's animal type. This allows orgs working with + * different animals to see contextually appropriate role names without + * changing the underlying schema. + */ + +interface RoleLabels { + cleaningCrew: string + sideWalkers: string + lessonAssistants: string + animalHandlers: string +} + +/** + * Default labels use horse-specific terms to maintain backward compatibility + * with the existing equestrian customer. New orgs with a different animalType + * will get their own contextual labels via the overrides below. + */ +const defaultLabels: RoleLabels = { + cleaningCrew: 'cleaning crew', + sideWalkers: 'side walkers', + lessonAssistants: 'lesson assistants', + animalHandlers: 'animal handlers', +} + +const labelsByAnimalType: Record> = { + horses: { + // defaults already use horse-specific terms; only override if needed + }, + dogs: { + sideWalkers: 'dog walkers', + lessonAssistants: 'activity assistants', + animalHandlers: 'dog handlers', + }, + cats: { + sideWalkers: 'socialization helpers', + lessonAssistants: 'session assistants', + animalHandlers: 'cat handlers', + }, + wildlife: { + sideWalkers: 'habitat assistants', + lessonAssistants: 'program assistants', + animalHandlers: 'wildlife handlers', + }, +} + +export function getRoleLabels(animalType?: string | null): RoleLabels { + const overrides = animalType ? labelsByAnimalType[animalType] : undefined + return { ...defaultLabels, ...overrides } +} + +interface RoleDescription { + cleaningCrew: string + sideWalkers: string + lessonAssistants: string + animalHandlers: string +} + +/** + * Default descriptions use horse-specific language to match the existing + * equestrian customer's experience. Overrides below provide contextual + * descriptions for other animal types. + */ +const defaultDescriptions: RoleDescription = { + cleaningCrew: + 'Cleaning crew volunteers help maintain the facility, check waterers, sweep common areas, and handle other miscellaneous cleaning tasks. No prior experience with animals is required.', + sideWalkers: + 'Side walkers walk alongside participants helping to support them during sessions. No prior experience with animals needed. Must be able to walk on uneven surfaces.', + lessonAssistants: + 'Lesson assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.', + animalHandlers: + 'Animal handlers guide and manage animals during sessions. Should have 1+ years of experience with animals, and must be able to walk on uneven surfaces.', +} + +const descriptionsByAnimalType: Record> = { + horses: { + // defaults already use horse-appropriate language + }, + dogs: { + sideWalkers: + 'Dog walkers take dogs on scheduled walks and help with socialization activities. Must be comfortable handling dogs of varying sizes.', + lessonAssistants: + 'Activity assistants help run training sessions and enrichment programs. Should have experience with dog behavior and basic commands.', + animalHandlers: + 'Dog handlers manage dogs during adoption events and therapy visits. Should have 1+ years of experience with dogs.', + }, +} + +export function getRoleDescriptions( + animalType?: string | null, +): RoleDescription { + const overrides = animalType + ? descriptionsByAnimalType[animalType] + : undefined + return { ...defaultDescriptions, ...overrides } +} diff --git a/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql b/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql index 49271fe..71e1c20 100644 --- a/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql +++ b/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql @@ -22,27 +22,7 @@ DROP INDEX "_horseLeader_B_index"; -- DropIndex DROP INDEX "_horseLeader_AB_unique"; --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "Horse"; -PRAGMA foreign_keys=on; - --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "HorseAssignment"; -PRAGMA foreign_keys=on; - --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "_EventToHorse"; -PRAGMA foreign_keys=on; - --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "_horseLeader"; -PRAGMA foreign_keys=on; - --- CreateTable +-- CreateTable (before dropping Horse so we can migrate data) CREATE TABLE "Animal" ( "id" TEXT NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, @@ -59,6 +39,10 @@ CREATE TABLE "Animal" ( CONSTRAINT "Animal_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image" ("fileId") ON DELETE SET NULL ON UPDATE CASCADE ); +-- Migrate existing Horse data into Animal +INSERT INTO "Animal" ("id", "name", "species", "notes", "status", "updatedAt", "cooldown", "cooldownStartDate", "cooldownEndDate", "orgId", "imageId") +SELECT "id", "name", 'horse', "notes", "status", "updatedAt", "cooldown", "cooldownStartDate", "cooldownEndDate", "orgId", "imageId" FROM "Horse"; + -- CreateTable CREATE TABLE "AnimalAssignment" ( "id" TEXT NOT NULL PRIMARY KEY, @@ -70,6 +54,10 @@ CREATE TABLE "AnimalAssignment" ( CONSTRAINT "AnimalAssignment_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- Migrate existing HorseAssignment data into AnimalAssignment +INSERT INTO "AnimalAssignment" ("id", "eventId", "userId", "animalId") +SELECT "id", "eventId", "userId", "horseId" FROM "HorseAssignment"; + -- CreateTable CREATE TABLE "_AnimalToEvent" ( "A" TEXT NOT NULL, @@ -78,6 +66,10 @@ CREATE TABLE "_AnimalToEvent" ( CONSTRAINT "_AnimalToEvent_B_fkey" FOREIGN KEY ("B") REFERENCES "Event" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- Migrate existing Horse-Event join data +INSERT INTO "_AnimalToEvent" ("A", "B") +SELECT "A", "B" FROM "_EventToHorse"; + -- CreateTable CREATE TABLE "_animalHandler" ( "A" TEXT NOT NULL, @@ -86,6 +78,27 @@ CREATE TABLE "_animalHandler" ( CONSTRAINT "_animalHandler_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- Migrate existing horseLeader join data +INSERT INTO "_animalHandler" ("A", "B") +SELECT "A", "B" FROM "_horseLeader"; + +-- Now safe to drop old tables +PRAGMA foreign_keys=off; +DROP TABLE "Horse"; +PRAGMA foreign_keys=on; + +PRAGMA foreign_keys=off; +DROP TABLE "HorseAssignment"; +PRAGMA foreign_keys=on; + +PRAGMA foreign_keys=off; +DROP TABLE "_EventToHorse"; +PRAGMA foreign_keys=on; + +PRAGMA foreign_keys=off; +DROP TABLE "_horseLeader"; +PRAGMA foreign_keys=on; + -- RedefineTables PRAGMA foreign_keys=OFF; CREATE TABLE "new_Event" ( @@ -101,7 +114,7 @@ CREATE TABLE "new_Event" ( "isPrivate" BOOLEAN NOT NULL DEFAULT false, CONSTRAINT "Event_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); -INSERT INTO "new_Event" ("cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "start", "title") SELECT "cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "start", "title" FROM "Event"; +INSERT INTO "new_Event" ("cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "animalHandlersReq", "start", "title") SELECT "cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "horseLeadersReq", "start", "title" FROM "Event"; DROP TABLE "Event"; ALTER TABLE "new_Event" RENAME TO "Event"; CREATE UNIQUE INDEX "Event_id_key" ON "Event"("id"); diff --git a/tailwind.config.ts b/tailwind.config.ts index 118a4c7..94aa638 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -75,13 +75,14 @@ export default { foreground: 'hsl(var(--color-card-foreground))', }, sidebar: { - DEFAULT: 'hsl(var(--color-sidebar))', - foreground: 'hsl(var(--color-sidebar-foreground))', - border: 'hsl(var(--color-sidebar-border))', - active: 'hsl(var(--color-sidebar-active))', - 'active-foreground': 'hsl(var(--color-sidebar-active-foreground))', - }, - day: { + DEFAULT: 'hsl(var(--color-sidebar))', + foreground: 'hsl(var(--color-sidebar-foreground))', + border: 'hsl(var(--color-sidebar-border))', + active: 'hsl(var(--color-sidebar-active))', + 'active-foreground': + 'hsl(var(--color-sidebar-active-foreground))', + }, + day: { 100: 'hsl(var(--color-day-100))', 200: 'hsl(var(--color-day-200))', 300: 'hsl(var(--color-day-300))', diff --git a/tests/e2e/admin.test.ts b/tests/e2e/admin.test.ts index ace6923..f3566f1 100644 --- a/tests/e2e/admin.test.ts +++ b/tests/e2e/admin.test.ts @@ -12,9 +12,14 @@ test('Admin can log in and see admin panel', async ({ page }) => { .fill(adminUser.username) await page.getByLabel(/^password$/i).fill(password) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - await expect(page.getByRole('link', { name: /admin/i })).toBeVisible() + // Admin login redirects to /admin which redirects to /admin/users + await expect(page).toHaveURL(/\/admin/) + + // Sidebar shows admin nav links + await expect(page.getByRole('link', { name: /users/i })).toBeVisible() + await expect(page.getByRole('link', { name: /animals/i })).toBeVisible() + await expect(page.getByRole('link', { name: /email/i })).toBeVisible() }) test('Admin can edit users with proper validation for height and feet', async ({ @@ -29,14 +34,13 @@ test('Admin can edit users with proper validation for height and feet', async ({ .fill(adminUser.username) await page.getByLabel(/^password$/i).fill(password) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - await expect(page.getByRole('link', { name: /admin/i })).toBeVisible() + // Admin login redirects to /admin/users + await expect(page).toHaveURL(/\/admin/) - // Navigate to users page - await page.getByRole('link', { name: /admin/i }).click() - await page.getByRole('menuitem', { name: 'Users' }).click() - await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible() + // Navigate to users page via sidebar + await page.getByRole('link', { name: /users/i }).click() + await expect(page.getByRole('heading', { name: /volunteers/i })).toBeVisible() // Open Edit user page await page.getByText('open menu').first().click() diff --git a/tests/e2e/onboarding.test.ts b/tests/e2e/onboarding.test.ts index 55c0de2..ed32025 100644 --- a/tests/e2e/onboarding.test.ts +++ b/tests/e2e/onboarding.test.ts @@ -92,16 +92,8 @@ test('onboarding with link', async ({ page }) => { await page.getByRole('button', { name: /Create an account/i }).click() - await expect(page).toHaveURL(`/`) - - await page.getByRole('link', { name: onboardingData.name }).click() - await page.getByRole('menuitem', { name: /profile/i }).click() - - await expect(page).toHaveURL(`/users/${onboardingData.username}`) - - await page.getByRole('link', { name: onboardingData.name }).click() - await page.getByRole('menuitem', { name: /logout/i }).click() - await expect(page).toHaveURL(`/`) + // After onboarding, user has no org — lands on org-setup + await expect(page).toHaveURL(`/org-setup`) // have to do this here because we didn't use insertNewUser (because we're testing user create) await deleteUserByUsername(onboardingData.username) @@ -162,9 +154,10 @@ test('login as existing user', async ({ page }) => { await page.getByRole('textbox', { name: /username/i }).fill(user.username) await page.getByLabel(/^password$/i).fill(password) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - await expect(page.getByRole('link', { name: user.name })).toBeVisible() + // After login, user lands on calendar (or admin redirect) + // Sidebar shows the user's name + await expect(page.getByText(user.name)).toBeVisible() }) test('reset password with a link', async ({ page }) => { @@ -215,9 +208,8 @@ test('reset password with a link', async ({ page }) => { await page.getByLabel(/^password$/i).fill(newPassword) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - - await expect(page.getByRole('link', { name: user.name })).toBeVisible() + // Sidebar shows the user's name after successful login + await expect(page.getByText(user.name)).toBeVisible() }) test('reset password with a short code', async ({ page }) => { diff --git a/tests/playwright-utils.ts b/tests/playwright-utils.ts index 9e4ce47..ad3de8b 100644 --- a/tests/playwright-utils.ts +++ b/tests/playwright-utils.ts @@ -13,11 +13,27 @@ export function deleteUserByUsername(username: string) { return prisma.user.delete({ where: { username } }) } +export async function getOrCreateDefaultOrg() { + let org = await prisma.organization.findFirst() + if (!org) { + org = await prisma.organization.create({ + data: { + name: 'Test Org', + slug: 'test-org', + animalType: 'horses', + }, + }) + } + return org +} + export async function insertNewUser({ password }: { password?: string } = {}) { const userData = createUser() + const org = await getOrCreateDefaultOrg() const user = await prisma.user.create({ data: { ...userData, + org: { connect: { id: org.id } }, password: { create: { hash: await getPasswordHash(password || userData.username), @@ -51,9 +67,11 @@ export async function insertNewAdmin({ password }: { password?: string } = {}) { }) } + const org = await getOrCreateDefaultOrg() const user = await prisma.user.create({ data: { ...userData, + org: { connect: { id: org.id } }, password: { create: { hash: await getPasswordHash(password || userData.username),