feat(ST-1136): mobile Settings-pagina + LogoutButton (T-325/T-326/T-327)
- app/(mobile)/m/settings/page.tsx — read-only account-info, product-selector (hergebruikt ActivateProductButton + setActiveProductAction met redirectTo /m/products/[id]/solo), QR-pairing-instructie, logout - components/mobile/logout-button.tsx — AlertDialog "Uitloggen?" met bevestig + annuleer; demo-user mag uitloggen (geen demo-block) - Tests: LogoutButton render + open + bevestig (logoutAction) + annuleer Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
13ab53ab8d
commit
0a3dc401b7
3 changed files with 194 additions and 0 deletions
46
__tests__/components/mobile/logout-button.test.tsx
Normal file
46
__tests__/components/mobile/logout-button.test.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
|
||||
const { logoutMock } = vi.hoisted(() => ({
|
||||
logoutMock: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('@/actions/auth', () => ({ logoutAction: logoutMock }))
|
||||
|
||||
import { LogoutButton } from '@/components/mobile/logout-button'
|
||||
|
||||
beforeEach(() => {
|
||||
logoutMock.mockClear()
|
||||
})
|
||||
|
||||
describe('LogoutButton', () => {
|
||||
it('toont initieel alleen de Uitloggen-knop, geen dialog', () => {
|
||||
render(<LogoutButton />)
|
||||
expect(screen.getByRole('button', { name: /Uitloggen/ })).toBeTruthy()
|
||||
expect(screen.queryByText(/Weet je zeker/)).toBeNull()
|
||||
})
|
||||
|
||||
it('opent AlertDialog bij klikken op de knop', () => {
|
||||
render(<LogoutButton />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
|
||||
expect(screen.getByText('Uitloggen?')).toBeTruthy()
|
||||
expect(screen.getByText(/Weet je zeker/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('roept logoutAction aan op bevestigen', async () => {
|
||||
const { container } = render(<LogoutButton />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
|
||||
// Het body-portal wordt buiten container gerenderd; query op document.body.
|
||||
const allButtons = Array.from(document.body.querySelectorAll('button'))
|
||||
const confirmBtn = allButtons.find(b => b.textContent?.trim() === 'Uitloggen' && !container.contains(b)) ?? allButtons[allButtons.length - 1]
|
||||
fireEvent.click(confirmBtn)
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('roept logoutAction NIET aan bij annuleren', () => {
|
||||
render(<LogoutButton />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
|
||||
fireEvent.click(screen.getByText('Annuleren'))
|
||||
expect(logoutMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
92
app/(mobile)/m/settings/page.tsx
Normal file
92
app/(mobile)/m/settings/page.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// PBI-11 / ST-1136: Mobile Settings — read-only account, product-selector,
|
||||
// QR-pairing-instructie, logout. Eigenlijke productactivering loopt via de
|
||||
// bestaande setActiveProductAction (ActivateProductButton).
|
||||
|
||||
import Link from 'next/link'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { requireSession } from '@/lib/auth-guard'
|
||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||
import { LogoutButton } from '@/components/mobile/logout-button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Settings',
|
||||
}
|
||||
|
||||
export default async function MobileSettingsPage() {
|
||||
const session = await requireSession()
|
||||
|
||||
const [user, products] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { username: true, is_demo: true, active_product_id: true },
|
||||
}),
|
||||
prisma.product.findMany({
|
||||
where: { archived: false, ...productAccessFilter(session.userId) },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const isDemo = user?.is_demo ?? false
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 space-y-6 max-w-md mx-auto w-full">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
|
||||
<section aria-labelledby="account-heading" className="space-y-2">
|
||||
<h2 id="account-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Account</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-medium">{user?.username ?? '—'}</span>
|
||||
{isDemo && (
|
||||
<Badge className="bg-status-todo/15 text-status-todo border-status-todo/30">Demo</Badge>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="product-heading" className="space-y-2">
|
||||
<h2 id="product-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Actief product</h2>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Geen producten beschikbaar.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded border border-border">
|
||||
{products.map((p) => {
|
||||
const active = p.id === user?.active_product_id
|
||||
return (
|
||||
<li key={p.id} className="flex items-center justify-between px-3 py-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm truncate">{p.name}</span>
|
||||
{active && (
|
||||
<Badge className="bg-primary/15 text-primary border-primary/30">Actief</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!active && (
|
||||
<ActivateProductButton
|
||||
productId={p.id}
|
||||
isDemo={isDemo}
|
||||
redirectTo={`/m/products/${p.id}/solo`}
|
||||
label="Activeer"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="qr-heading" className="space-y-2">
|
||||
<h2 id="qr-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Inloggen op desktop</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open <Link href="/login" className="text-primary hover:underline">scrum4me.app/login</Link> op je desktop om in te loggen via QR-code. QR-pairing start vanaf de desktop.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="logout-heading" className="space-y-2 pt-2">
|
||||
<h2 id="logout-heading" className="sr-only">Uitloggen</h2>
|
||||
<LogoutButton />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
components/mobile/logout-button.tsx
Normal file
56
components/mobile/logout-button.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { logoutAction } from '@/actions/auth'
|
||||
|
||||
export function LogoutButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
function confirm() {
|
||||
startTransition(async () => {
|
||||
await logoutAction()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full justify-center gap-2"
|
||||
>
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
Uitloggen
|
||||
</Button>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Uitloggen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Weet je zeker dat je wilt uitloggen?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setOpen(false)}>Annuleren</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={pending} onClick={confirm}>
|
||||
{pending ? 'Bezig…' : 'Uitloggen'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue