Merge pull request #81 from madhura68/feat/ST-1134-mobile-shell-foundation
feat(PBI-11): mobile-shell met landscape-lock — settings + backlog + solo
This commit is contained in:
commit
db8be67d9b
26 changed files with 1125 additions and 21 deletions
85
__tests__/actions/auth.test.ts
Normal file
85
__tests__/actions/auth.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { redirectMock, verifyUserMock, headerGetMock, sessionSaveMock } = vi.hoisted(() => ({
|
||||
redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }),
|
||||
verifyUserMock: vi.fn(),
|
||||
headerGetMock: vi.fn(),
|
||||
sessionSaveMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn().mockResolvedValue({}),
|
||||
headers: vi.fn().mockResolvedValue({ get: headerGetMock }),
|
||||
}))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn().mockResolvedValue({
|
||||
userId: '',
|
||||
isDemo: false,
|
||||
save: sessionSaveMock,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: 't' } }))
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
verifyUser: verifyUserMock,
|
||||
registerUser: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) }))
|
||||
|
||||
import { loginAction } from '@/actions/auth'
|
||||
|
||||
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1'
|
||||
const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1'
|
||||
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0 Safari/537.36'
|
||||
|
||||
function fd(username: string, password: string) {
|
||||
const f = new FormData()
|
||||
f.set('username', username)
|
||||
f.set('password', password)
|
||||
return f
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
redirectMock.mockClear()
|
||||
verifyUserMock.mockReset()
|
||||
headerGetMock.mockReset()
|
||||
sessionSaveMock.mockReset()
|
||||
})
|
||||
|
||||
describe('loginAction UA-redirect', () => {
|
||||
it('phone-UA + actief product → /m/products/[id]/solo', async () => {
|
||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
||||
headerGetMock.mockReturnValue(IPHONE_UA)
|
||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
|
||||
})
|
||||
|
||||
it('phone-UA zonder actief product → /m/settings', async () => {
|
||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: null })
|
||||
headerGetMock.mockReturnValue(IPHONE_UA)
|
||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/settings')
|
||||
})
|
||||
|
||||
it('tablet-UA (iPad) → /dashboard', async () => {
|
||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
||||
headerGetMock.mockReturnValue(IPAD_UA)
|
||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
|
||||
})
|
||||
|
||||
it('desktop-UA → /dashboard', async () => {
|
||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
||||
headerGetMock.mockReturnValue(DESKTOP_UA)
|
||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
|
||||
})
|
||||
|
||||
it('geen UA-header → /dashboard', async () => {
|
||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
||||
headerGetMock.mockReturnValue(null)
|
||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
|
||||
})
|
||||
|
||||
it('demo-user op phone volgt dezelfde routing', async () => {
|
||||
verifyUserMock.mockResolvedValue({ id: 'demo', is_demo: true, active_product_id: 'p1' })
|
||||
headerGetMock.mockReturnValue(IPHONE_UA)
|
||||
await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
|
||||
})
|
||||
})
|
||||
38
__tests__/app/m-products-page.test.ts
Normal file
38
__tests__/app/m-products-page.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Lichte regressie-tests voor de mobile backlog-page. Server-component render
|
||||
// vereist te veel mocking; we asserten op statische source-eigenschappen die
|
||||
// kritisch zijn voor de mobile-shell (cookie-key gescheiden, /m/-paden).
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/page.tsx')
|
||||
const src = readFileSync(PAGE, 'utf-8')
|
||||
|
||||
describe('mobile backlog page (ST-1137)', () => {
|
||||
it('gebruikt gescheiden cookie-key (backlog-{id}-mobile)', () => {
|
||||
// Beslissing C: tab-mode-gebruikers vervuilen desktop-split niet.
|
||||
expect(src).toMatch(/cookieKey=\{`backlog-\$\{id\}-mobile`\}/)
|
||||
})
|
||||
|
||||
it('closePath en TaskDialog redirect blijven onder /m/products/', () => {
|
||||
expect(src).toContain('const closePath = `/m/products/${id}`')
|
||||
})
|
||||
|
||||
it('hergebruikt BacklogHydrationWrapper + BacklogSplitPane (geen content-componenten dupliceren)', () => {
|
||||
expect(src).toContain('BacklogHydrationWrapper')
|
||||
expect(src).toContain('BacklogSplitPane')
|
||||
expect(src).toContain('PbiList')
|
||||
expect(src).toContain('StoryPanel')
|
||||
expect(src).toContain('TaskPanel')
|
||||
})
|
||||
|
||||
it('auth via requireSession() (gedeelde guard)', () => {
|
||||
expect(src).toContain("from '@/lib/auth-guard'")
|
||||
expect(src).toContain('requireSession()')
|
||||
})
|
||||
|
||||
it('rendert TaskDialog op ?newTask en EditTaskLoader op ?editTask', () => {
|
||||
expect(src).toContain('{newTask &&')
|
||||
expect(src).toContain('{editTask && !newTask &&')
|
||||
})
|
||||
})
|
||||
35
__tests__/app/m-solo-page.test.ts
Normal file
35
__tests__/app/m-solo-page.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// ST-1138: regressie-vangnet voor mobile solo-page (server component).
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/solo/page.tsx')
|
||||
const TASK_DETAIL = resolve(process.cwd(), 'components/solo/task-detail-dialog.tsx')
|
||||
|
||||
describe('mobile solo page (ST-1138)', () => {
|
||||
const src = readFileSync(PAGE, 'utf-8')
|
||||
|
||||
it('hergebruikt SoloBoard zonder content-aanpassingen', () => {
|
||||
expect(src).toContain('SoloBoard')
|
||||
expect(src).toContain("from '@/components/solo/solo-board'")
|
||||
})
|
||||
|
||||
it('auth via gedeelde requireSession()', () => {
|
||||
expect(src).toContain("from '@/lib/auth-guard'")
|
||||
expect(src).toContain('requireSession()')
|
||||
})
|
||||
|
||||
it('geeft NoActiveSprint terug als geen actieve sprint (zelfde gedrag als desktop)', () => {
|
||||
expect(src).toContain('NoActiveSprint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskDetailDialog erft mobile-fullscreen (ST-1138 T-332 verify-only)', () => {
|
||||
// Beslissing A: TaskDetailDialog gebruikt entityDialogContentClasses; mobile-classes
|
||||
// komen automatisch door uit T-317. Dit test bewijst de wiring blijft staan.
|
||||
const src = readFileSync(TASK_DETAIL, 'utf-8')
|
||||
|
||||
it('rendert DialogContent met entityDialogContentClasses (geen eigen className-override)', () => {
|
||||
expect(src).toContain('className={entityDialogContentClasses}')
|
||||
})
|
||||
})
|
||||
73
__tests__/components/mobile/landscape-guard.test.tsx
Normal file
73
__tests__/components/mobile/landscape-guard.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, act } from '@testing-library/react'
|
||||
import { LandscapeGuard } from '@/components/mobile/landscape-guard'
|
||||
|
||||
type Listener = (e: MediaQueryListEvent) => void
|
||||
|
||||
function mockMatchMedia(initialPortrait: boolean) {
|
||||
let matches = initialPortrait
|
||||
let listener: Listener | null = null
|
||||
|
||||
const mql = {
|
||||
get matches() { return matches },
|
||||
media: '(orientation: portrait)',
|
||||
onchange: null,
|
||||
addEventListener: (_: string, l: Listener) => { listener = l },
|
||||
removeEventListener: () => { listener = null },
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: () => mql,
|
||||
})
|
||||
|
||||
return {
|
||||
setPortrait(p: boolean) {
|
||||
matches = p
|
||||
if (listener) listener({ matches: p } as MediaQueryListEvent)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('LandscapeGuard', () => {
|
||||
beforeEach(() => {})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders children always', () => {
|
||||
mockMatchMedia(false)
|
||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
||||
expect(screen.getByText('kids')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows overlay in portrait', () => {
|
||||
mockMatchMedia(true)
|
||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
||||
expect(screen.getByRole('alert').textContent).toContain('Draai je telefoon naar landscape')
|
||||
// children blijven in DOM (geen unmount → SSE-streams blijven leven)
|
||||
expect(screen.getByText('kids')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides overlay in landscape', () => {
|
||||
mockMatchMedia(false)
|
||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles overlay on orientation change', () => {
|
||||
const ctl = mockMatchMedia(false)
|
||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
act(() => ctl.setPortrait(true))
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
act(() => ctl.setPortrait(false))
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
57
__tests__/components/mobile/mobile-tab-bar.test.tsx
Normal file
57
__tests__/components/mobile/mobile-tab-bar.test.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MobileTabBar } from '@/components/mobile/mobile-tab-bar'
|
||||
|
||||
let pathname = '/m/products/p1'
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => pathname,
|
||||
}))
|
||||
|
||||
function setPathname(p: string) { pathname = p }
|
||||
|
||||
describe('MobileTabBar', () => {
|
||||
it('toont 3 tabs als activeProductId aanwezig is', () => {
|
||||
setPathname('/m/products/p1')
|
||||
render(<MobileTabBar activeProductId="p1" />)
|
||||
expect(screen.getByLabelText('Backlog')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Solo')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toont alleen Settings als activeProductId null is', () => {
|
||||
setPathname('/m/settings')
|
||||
render(<MobileTabBar activeProductId={null} />)
|
||||
expect(screen.queryByLabelText('Backlog')).toBeNull()
|
||||
expect(screen.queryByLabelText('Solo')).toBeNull()
|
||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Backlog-tab is aria-current op /m/products/[id]', () => {
|
||||
setPathname('/m/products/p1')
|
||||
render(<MobileTabBar activeProductId="p1" />)
|
||||
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBe('page')
|
||||
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
|
||||
it('Solo-tab is aria-current op /m/products/[id]/solo', () => {
|
||||
setPathname('/m/products/p1/solo')
|
||||
render(<MobileTabBar activeProductId="p1" />)
|
||||
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBe('page')
|
||||
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
|
||||
it('Settings-tab is aria-current op /m/settings', () => {
|
||||
setPathname('/m/settings')
|
||||
render(<MobileTabBar activeProductId="p1" />)
|
||||
expect(screen.getByLabelText('Settings').getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('tap-targets >=44x44 (h-14 = 56px breedtevulling per tab)', () => {
|
||||
setPathname('/m/products/p1')
|
||||
render(<MobileTabBar activeProductId="p1" />)
|
||||
const tab = screen.getByLabelText('Backlog')
|
||||
expect(tab.className).toContain('h-14')
|
||||
expect(tab.className).toContain('flex-1')
|
||||
})
|
||||
})
|
||||
38
__tests__/components/shared/entity-dialog-layout.test.ts
Normal file
38
__tests__/components/shared/entity-dialog-layout.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { entityDialogContentClasses } from '@/components/shared/entity-dialog-layout'
|
||||
|
||||
describe('entityDialogContentClasses', () => {
|
||||
it('bevat mobile-fullscreen classes (<640px)', () => {
|
||||
const cls = entityDialogContentClasses
|
||||
expect(cls).toContain('max-sm:w-screen')
|
||||
expect(cls).toContain('max-sm:h-screen')
|
||||
expect(cls).toContain('max-sm:max-w-none')
|
||||
expect(cls).toContain('max-sm:rounded-none')
|
||||
})
|
||||
|
||||
it('behoudt desktop-classes (>=640px)', () => {
|
||||
const cls = entityDialogContentClasses
|
||||
expect(cls).toContain('sm:max-w-[90vw]')
|
||||
expect(cls).toContain('sm:max-h-[85vh]')
|
||||
expect(cls).toContain('lg:max-w-[50vw]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('alle entity-dialogen gebruiken entityDialogContentClasses', () => {
|
||||
// Regressie-vangnet: voorkomt dat een dialog zijn eigen className meegeeft en
|
||||
// daarmee de gedeelde mobile-fullscreen-classes ontwijkt.
|
||||
const files = [
|
||||
'app/_components/tasks/task-dialog.tsx',
|
||||
'components/solo/task-detail-dialog.tsx',
|
||||
'components/backlog/pbi-dialog.tsx',
|
||||
'components/backlog/story-dialog.tsx',
|
||||
]
|
||||
for (const f of files) {
|
||||
it(`${f} importeert + gebruikt entityDialogContentClasses`, () => {
|
||||
const src = readFileSync(resolve(process.cwd(), f), 'utf-8')
|
||||
expect(src).toContain('entityDialogContentClasses')
|
||||
})
|
||||
}
|
||||
})
|
||||
49
__tests__/lib/auth-guard.test.ts
Normal file
49
__tests__/lib/auth-guard.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const getSessionMock = vi.fn()
|
||||
const isPairedSessionExpiredMock = vi.fn()
|
||||
const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') })
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ getSession: getSessionMock }))
|
||||
vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock }))
|
||||
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
|
||||
|
||||
describe('requireSession', () => {
|
||||
beforeEach(() => {
|
||||
getSessionMock.mockReset()
|
||||
isPairedSessionExpiredMock.mockReset()
|
||||
redirectMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('redirect /login als userId ontbreekt', async () => {
|
||||
getSessionMock.mockResolvedValue({ userId: undefined, destroy: vi.fn() })
|
||||
isPairedSessionExpiredMock.mockReturnValue(false)
|
||||
const { requireSession } = await import('@/lib/auth-guard')
|
||||
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
|
||||
expect(redirectMock).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('vernietigt + redirect /login als paired-sessie verlopen is', async () => {
|
||||
const destroy = vi.fn().mockResolvedValue(undefined)
|
||||
getSessionMock.mockResolvedValue({ userId: 'u1', destroy })
|
||||
isPairedSessionExpiredMock.mockReturnValue(true)
|
||||
const { requireSession } = await import('@/lib/auth-guard')
|
||||
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
|
||||
expect(destroy).toHaveBeenCalled()
|
||||
expect(redirectMock).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('geeft sessie terug als alles ok', async () => {
|
||||
const sess = { userId: 'u1', destroy: vi.fn() }
|
||||
getSessionMock.mockResolvedValue(sess)
|
||||
isPairedSessionExpiredMock.mockReturnValue(false)
|
||||
const { requireSession } = await import('@/lib/auth-guard')
|
||||
const result = await requireSession()
|
||||
expect(result).toBe(sess)
|
||||
expect(redirectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
37
__tests__/lib/user-agent.test.ts
Normal file
37
__tests__/lib/user-agent.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { isPhoneUA } from '@/lib/user-agent'
|
||||
|
||||
describe('isPhoneUA', () => {
|
||||
it('iPhone Safari Mobile → true', () => {
|
||||
const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'
|
||||
expect(isPhoneUA(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('Android Chrome (phone) → true', () => {
|
||||
const ua = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36'
|
||||
expect(isPhoneUA(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('iPad → false (geen Mobi)', () => {
|
||||
const ua = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/604.1'
|
||||
expect(isPhoneUA(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('Android tablet (Galaxy Tab) → false', () => {
|
||||
const ua = 'Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
expect(isPhoneUA(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('Desktop Chrome → false', () => {
|
||||
const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
expect(isPhoneUA(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('null → false', () => {
|
||||
expect(isPhoneUA(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('lege string → false', () => {
|
||||
expect(isPhoneUA('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ import { z } from 'zod'
|
|||
import { registerUser, verifyUser } from '@/lib/auth'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { isPhoneUA } from '@/lib/user-agent'
|
||||
|
||||
async function getClientIp(): Promise<string> {
|
||||
const h = await headers()
|
||||
|
|
@ -74,6 +75,13 @@ export async function loginAction(_prevState: unknown, formData: FormData) {
|
|||
session.isDemo = user.is_demo
|
||||
await session.save()
|
||||
|
||||
// PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell.
|
||||
// Tablets en desktop volgen het bestaande /dashboard-pad.
|
||||
const ua = (await headers()).get('user-agent')
|
||||
if (isPhoneUA(ua)) {
|
||||
redirect(user.active_product_id ? `/m/products/${user.active_product_id}/solo` : '/m/settings')
|
||||
}
|
||||
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { isPairedSessionExpired } from '@/lib/auth/pairing'
|
||||
import { requireSession } from '@/lib/auth-guard'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { NavBar } from '@/components/shared/nav-bar'
|
||||
|
|
@ -14,18 +11,7 @@ import { AlertToast } from '@/components/shared/alert-toast'
|
|||
import { Suspense } from 'react'
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
||||
if (!session.userId) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// ST-1002 (M10): paired-sessies (via QR-pairing) hebben een eigen kortere TTL.
|
||||
// Vervallen → vernietig en stuur naar /login.
|
||||
if (isPairedSessionExpired(session)) {
|
||||
session.destroy()
|
||||
redirect('/login')
|
||||
}
|
||||
const session = await requireSession()
|
||||
|
||||
const [user, userRoles, accessibleProducts] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
|
|
|
|||
40
app/(mobile)/layout.tsx
Normal file
40
app/(mobile)/layout.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// PBI-11 / ST-1134: Mobile shell-layout. Eigen route group (mobile) — nested
|
||||
// layout in (app)/ kan parent NavBar/StatusBar/MinWidthBanner niet onderdrukken.
|
||||
// Auth via gedeelde requireSession() (lib/auth-guard.ts), hergebruikt door
|
||||
// (app)/layout.tsx.
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { requireSession } from '@/lib/auth-guard'
|
||||
import { LandscapeGuard } from '@/components/mobile/landscape-guard'
|
||||
import { MobileTabBar } from '@/components/mobile/mobile-tab-bar'
|
||||
|
||||
export default async function MobileLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireSession()
|
||||
|
||||
// Active product nodig voor de tab-bar (Backlog/Solo-tabs verbergen als geen actief product).
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { active_product_id: true },
|
||||
})
|
||||
|
||||
let activeProductId: string | null = null
|
||||
if (user?.active_product_id) {
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
activeProductId = product?.id ?? null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
||||
<LandscapeGuard>
|
||||
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0 pb-14">
|
||||
{children}
|
||||
</main>
|
||||
<MobileTabBar activeProductId={activeProductId} />
|
||||
</LandscapeGuard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10).
|
||||
//
|
||||
// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende
|
||||
// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams
|
||||
// Server Component achter de (mobile)/layout.tsx auth-guard (route group
|
||||
// (mobile) per ST-1134/PBI-11) — onbekende mobielen worden eerst naar /login
|
||||
// gestuurd. Bewust géén searchParams
|
||||
// uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen
|
||||
// client-side leesbaar is. De Client Component PairConfirmation parseert
|
||||
// location.hash en doet de Server Action-calls.
|
||||
144
app/(mobile)/m/products/[id]/page.tsx
Normal file
144
app/(mobile)/m/products/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// PBI-11 / ST-1137: Mobile Product Backlog. Wraps de 3-paneel-backlog in de
|
||||
// mobile-shell. BacklogSplitPane rendert automatisch tab-mode op <1024px
|
||||
// (uit ST-1116). Cookie-key gescheiden van desktop zodat tab-mode-gebruikers
|
||||
// de desktop-split niet vervuilen (beslissing C in docs/plans/PBI-11-mobile-shell.md).
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { pbiStatusToApi } from '@/lib/task-status'
|
||||
import { requireSession } from '@/lib/auth-guard'
|
||||
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
||||
import { PbiList } from '@/components/backlog/pbi-list'
|
||||
import { StoryPanel } from '@/components/backlog/story-panel'
|
||||
import type { Story } from '@/components/backlog/story-panel'
|
||||
import { TaskPanel } from '@/components/backlog/task-panel'
|
||||
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
|
||||
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }>
|
||||
}
|
||||
|
||||
export default async function MobileProductBacklogPage({ params, searchParams }: Props) {
|
||||
const { id } = await params
|
||||
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||||
const closePath = `/m/products/${id}`
|
||||
|
||||
const session = await requireSession()
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const pbis = await prisma.pbi.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
})
|
||||
|
||||
const [stories, tasks] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
description: true,
|
||||
acceptance_criteria: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
pbi_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
}),
|
||||
prisma.task.findMany({
|
||||
where: { story: { pbi: { product_id: id } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
sort_order: true,
|
||||
story_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const storiesByPbi: Record<string, Story[]> = {}
|
||||
for (const story of stories) {
|
||||
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
|
||||
storiesByPbi[story.pbi_id].push(story)
|
||||
}
|
||||
|
||||
const tasksByStory: Record<string, typeof tasks> = {}
|
||||
for (const task of tasks) {
|
||||
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
|
||||
tasksByStory[task.story_id].push(task)
|
||||
}
|
||||
|
||||
const isDemo = session.isDemo ?? false
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<BacklogHydrationWrapper
|
||||
productId={id}
|
||||
initialData={{
|
||||
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
||||
storiesByPbi,
|
||||
tasksByStory,
|
||||
}}
|
||||
>
|
||||
<BacklogSplitPane
|
||||
cookieKey={`backlog-${id}-mobile`}
|
||||
defaultSplit={[20, 45, 35]}
|
||||
tabLabels={['PBI\'s', 'Stories', 'Taken']}
|
||||
panes={[
|
||||
<PbiList
|
||||
key="pbi"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
/>,
|
||||
<StoryPanel
|
||||
key="story"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
/>,
|
||||
<TaskPanel
|
||||
key="tasks"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
closePath={closePath}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</BacklogHydrationWrapper>
|
||||
|
||||
{newTask && (
|
||||
<TaskDialog
|
||||
storyId={storyIdParam}
|
||||
productId={id}
|
||||
closePath={closePath}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editTask && !newTask && (
|
||||
<Suspense fallback={<TaskDialogSkeleton />}>
|
||||
<EditTaskLoader
|
||||
taskId={editTask}
|
||||
userId={session.userId}
|
||||
productId={id}
|
||||
closePath={closePath}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
app/(mobile)/m/products/[id]/solo/page.tsx
Normal file
120
app/(mobile)/m/products/[id]/solo/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// PBI-11 / ST-1138: Mobile Solo Paneel — wraps de bestaande SoloBoard zonder
|
||||
// content-aanpassingen. 3-koloms-kanban blijft (overflow-x scrollt zijwaarts).
|
||||
// TaskDetailDialog krijgt full-screen-mobile via gedeelde
|
||||
// entityDialogContentClasses (beslissing A in docs/plans/PBI-11-mobile-shell.md;
|
||||
// ingebouwd via ST-1133/T-317).
|
||||
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireSession } from '@/lib/auth-guard'
|
||||
import { SoloBoard } from '@/components/solo/solo-board'
|
||||
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function MobileSoloProductPage({ params }: Props) {
|
||||
const { id } = await params
|
||||
const session = await requireSession()
|
||||
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'ACTIVE' },
|
||||
})
|
||||
|
||||
if (!sprint) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<NoActiveSprint productId={id} productName={product.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [rawTasks, rawUnassigned] = await Promise.all([
|
||||
prisma.task.findMany({
|
||||
where: {
|
||||
story: {
|
||||
sprint_id: sprint.id,
|
||||
assignee_id: session.userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
story: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ story: { pbi: { priority: 'asc' } } },
|
||||
{ story: { pbi: { sort_order: 'asc' } } },
|
||||
{ story: { sort_order: 'asc' } },
|
||||
{ priority: 'asc' },
|
||||
{ sort_order: 'asc' },
|
||||
],
|
||||
}),
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id, assignee_id: null },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: {
|
||||
select: { id: true, title: true, description: true, priority: true, status: true },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { sort_order: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const tasks: SoloTask[] = rawTasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
implementation_plan: t.implementation_plan,
|
||||
priority: t.priority,
|
||||
sort_order: t.sort_order,
|
||||
status: t.status as SoloTask['status'],
|
||||
verify_only: t.verify_only,
|
||||
verify_required: t.verify_required as SoloTask['verify_required'],
|
||||
story_id: t.story.id,
|
||||
story_code: t.story.code,
|
||||
story_title: t.story.title,
|
||||
task_code: t.code,
|
||||
}))
|
||||
|
||||
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
tasks: s.tasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
})),
|
||||
}))
|
||||
|
||||
return (
|
||||
<SoloBoard
|
||||
productId={id}
|
||||
sprintGoal={sprint.sprint_goal}
|
||||
tasks={tasks}
|
||||
unassignedStories={unassignedStories}
|
||||
isDemo={session.isDemo ?? false}
|
||||
currentUserId={session.userId}
|
||||
repoUrl={product.repo_url}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
32
components/mobile/landscape-guard.tsx
Normal file
32
components/mobile/landscape-guard.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
|
||||
export function LandscapeGuard({ children }: { children: React.ReactNode }) {
|
||||
const [isPortrait, setIsPortrait] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(orientation: portrait)')
|
||||
const update = () => setIsPortrait(mq.matches)
|
||||
update()
|
||||
mq.addEventListener('change', update)
|
||||
return () => mq.removeEventListener('change', update)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{isPortrait && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 bg-background text-foreground p-6"
|
||||
>
|
||||
<RotateCw className="size-12 text-primary" />
|
||||
<p className="text-base font-medium text-center">Draai je telefoon naar landscape</p>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
components/mobile/mobile-tab-bar.tsx
Normal file
68
components/mobile/mobile-tab-bar.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ListTree, Activity, Settings } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MobileTabBarProps {
|
||||
activeProductId: string | null
|
||||
}
|
||||
|
||||
export function MobileTabBar({ activeProductId }: MobileTabBarProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const tabs: Array<{ href: string; icon: typeof ListTree; label: string; match: (p: string) => boolean }> = []
|
||||
|
||||
if (activeProductId) {
|
||||
tabs.push(
|
||||
{
|
||||
href: `/m/products/${activeProductId}`,
|
||||
icon: ListTree,
|
||||
label: 'Backlog',
|
||||
match: (p) => p === `/m/products/${activeProductId}`,
|
||||
},
|
||||
{
|
||||
href: `/m/products/${activeProductId}/solo`,
|
||||
icon: Activity,
|
||||
label: 'Solo',
|
||||
match: (p) => p.startsWith(`/m/products/${activeProductId}/solo`),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
href: '/m/settings',
|
||||
icon: Settings,
|
||||
label: 'Settings',
|
||||
match: (p) => p.startsWith('/m/settings'),
|
||||
})
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Hoofdnavigatie"
|
||||
className="fixed bottom-0 left-0 right-0 z-40 flex border-t border-border bg-surface-container-low"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = tab.match(pathname)
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
aria-label={tab.label}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex-1 h-14 flex items-center justify-center transition-colors',
|
||||
active
|
||||
? 'bg-primary-container text-primary-container-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'
|
|||
export const entityDialogContentClasses = cn(
|
||||
'flex flex-col p-0 gap-0',
|
||||
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
|
||||
'max-sm:w-screen max-sm:h-screen max-sm:max-h-screen max-sm:max-w-none max-sm:rounded-none',
|
||||
'sm:max-w-[90vw] sm:max-h-[85vh]',
|
||||
'lg:max-w-[50vw] lg:min-w-[480px]',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ scrum4me/
|
|||
│ ├── (auth)/
|
||||
│ │ ├── login/page.tsx
|
||||
│ │ └── register/page.tsx
|
||||
│ ├── (app)/ # Beschermde routes
|
||||
│ │ ├── layout.tsx # Auth-check + navigatie
|
||||
│ ├── (app)/ # Beschermde routes (desktop + tablets)
|
||||
│ │ ├── layout.tsx # Auth-check (requireSession) + navigatie
|
||||
│ │ ├── dashboard/page.tsx # Productenlijst
|
||||
│ │ ├── products/
|
||||
│ │ │ ├── new/page.tsx
|
||||
|
|
@ -31,6 +31,16 @@ scrum4me/
|
|||
│ │ └── settings/
|
||||
│ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens
|
||||
│ │ └── tokens/page.tsx
|
||||
│ ├── (mobile)/ # Mobile-shell route group (telefoon-UA)
|
||||
│ │ ├── layout.tsx # Auth via gedeelde requireSession; geen NavBar/StatusBar
|
||||
│ │ └── m/
|
||||
│ │ ├── settings/page.tsx # Account + product-selector + QR-instructie + logout
|
||||
│ │ ├── pair/ # QR-pairing (verhuisd uit (app)/ — URL ongewijzigd)
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── pair-confirmation.tsx
|
||||
│ │ └── products/[id]/
|
||||
│ │ ├── page.tsx # Mobile Product Backlog (tab-mode op <1024px)
|
||||
│ │ └── solo/page.tsx # Mobile Solo (3-koloms-kanban)
|
||||
│ ├── api/ # REST API voor Claude Code
|
||||
│ │ ├── products/
|
||||
│ │ │ └── [id]/
|
||||
|
|
@ -54,6 +64,7 @@ scrum4me/
|
|||
│ ├── sprint/ # Sprint-componenten
|
||||
│ ├── products/ # ProductForm, TeamManager, ArchiveProductButton
|
||||
│ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton
|
||||
│ ├── mobile/ # LandscapeGuard, MobileTabBar, LogoutButton
|
||||
│ └── dnd/ # dnd-kit wrappers
|
||||
├── lib/
|
||||
│ ├── prisma.ts # Prisma Client singleton
|
||||
|
|
@ -107,6 +118,26 @@ scrum4me/
|
|||
**Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma.
|
||||
**Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert.
|
||||
|
||||
### Beslissing: Eigen route group `(mobile)` voor mobile-shell (PBI-11)
|
||||
**Keuze:** Telefoon-routes leven onder `app/(mobile)/m/*` met eigen `layout.tsx`, niet als nested directory in `(app)/m/*`.
|
||||
**Rationale:** Next.js layouts erven naar binnen — een nested layout in `(app)/m/` zou de NavBar/StatusBar/MinWidthBanner/SoloRealtimeBridge/NotificationsBridge erven van `(app)/layout.tsx` zonder die te kunnen onderdrukken. De mobile-shell heeft die chrome niet nodig (alleen bottom-tab-bar). Een eigen route group geeft een schone parent-layout. De auth-check is geëxtraheerd naar `lib/auth-guard.ts` `requireSession()` zodat `(app)/layout.tsx` en `(mobile)/layout.tsx` dezelfde guard delen.
|
||||
**Trade-off:** Twee layouts om te onderhouden, maar elk met een duidelijk afgebakende verantwoordelijkheid. Content-componenten (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) blijven volledig gedeeld — geen dubbele implementatie.
|
||||
|
||||
### Beslissing: UA-redirect via `Mobi`-substring (PBI-11)
|
||||
**Keuze:** `lib/user-agent.ts` `isPhoneUA()` test op `Mobi` in de UA-string. `loginAction` (`actions/auth.ts`) leest de header na `session.save()`; phone-UA → `/m/products/[active]/solo` (zonder actief product → `/m/settings`); tablet-UA en desktop → `/dashboard`.
|
||||
**Rationale:** `Mobi` is de standaard-heuristiek — aanwezig in iPhone Safari Mobile en Android Chrome op telefoons, afwezig op iPad en Android-tablet. Exact wat we willen: alleen telefoons krijgen de mobile-shell, tablets behouden de desktop-flow.
|
||||
**Trade-off:** Heuristieken zijn nooit 100%; wie via een mobile-emulatie (DevTools) wil testen kan UA spoofen.
|
||||
|
||||
### Beslissing: Gedeelde `entityDialogContentClasses` voor mobile-fullscreen (PBI-11)
|
||||
**Keuze:** Eén Tailwind-class-string in `components/shared/entity-dialog-layout.ts` met `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none` dekt alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog).
|
||||
**Rationale:** Dialog-fullscreen op mobile op vier plekken bewaken zou drift introduceren. De gedeelde constant geeft één bron van waarheid. Het regressie-vangnet (`__tests__/components/shared/entity-dialog-layout.test.ts`) verifieert dat elke dialog deze constant blijft gebruiken.
|
||||
**Trade-off:** Eén dialog kan niet afwijken zonder de constant te verlaten — bewuste keuze voor consistentie.
|
||||
|
||||
### Beslissing: Gescheiden SplitPane cookie-key voor mobile (PBI-11)
|
||||
**Keuze:** `BacklogSplitPane` op `app/(mobile)/m/products/[id]/page.tsx` gebruikt `cookieKey={\`backlog-${id}-mobile\`}` (versus desktop `backlog-${id}`).
|
||||
**Rationale:** Op mobile rendert de `SplitPane` in tab-mode (`<1024px`), waar split-percentages niet aangepast worden. Zonder gescheiden key zou dezelfde cookie hergebruikt worden — telefoon-rotaties of orientatie-wisselingen hadden anders ongewenste interactie met de desktop-split-state.
|
||||
**Trade-off:** Gebruikers die zowel mobile als desktop gebruiken hebben twee onafhankelijke split-instellingen, wat juist gewenst is.
|
||||
|
||||
---
|
||||
|
||||
## Zustand stores
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ v1 is een desktop-first fullstack webapplicatie waarmee een solo developer of kl
|
|||
- Integratie met externe tools (GitHub Issues, Linear, Jira) — v2
|
||||
- Notificaties en reminders — v2
|
||||
- Native mobiele app — web-first; een toekomstige mobiele variant richt zich uitsluitend op taken afvinken
|
||||
- Responsive layout voor schermen smaller dan 1024px — desktop-first in v1
|
||||
- Responsive layout voor schermen smaller dan 1024px — desktop-first hoofdpad. Voor telefoons (UA met `Mobi`) is er een aparte mobile-shell onder `/m/*` met drie schermen — zie sectie *Mobile shell* hieronder.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -534,10 +534,44 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo
|
|||
/todos (todo-lijst)
|
||||
/settings (profiel, account, product backlogs, rollen, API-tokens)
|
||||
/settings/tokens (API-tokenbeheer)
|
||||
|
||||
# Mobile-shell (telefoon-UA)
|
||||
/m/settings (account + product-selector + QR-instructie + logout)
|
||||
/m/products/:id (Product Backlog — tab-mode op <1024px)
|
||||
/m/products/:id/solo (Solo Paneel — 3-koloms-kanban met horizontal scroll)
|
||||
/m/pair (QR-pairing bevestiging — verhuisd uit (app)/ naar (mobile)/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile shell
|
||||
|
||||
**Prioriteit:** v1 — voor on-the-go gebruik (PBI-11)
|
||||
**Persona:** Lars onderweg / tussendoor
|
||||
|
||||
**Omschrijving:**
|
||||
Telefoon-gebruikers (UA met `Mobi`-substring) krijgen een minimale mobile-shell met drie schermen onder `/m/*`. Tablets (iPad, Android-tablet zonder `Mobi`) en desktop blijven het bestaande `/dashboard`-pad volgen. De mobile-shell hergebruikt zoveel mogelijk content-componenten van de desktop-app (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) — er is geen aparte mobile-implementatie van de business-logica.
|
||||
|
||||
**Architectuur in één regel:** eigen route group `app/(mobile)/` met eigen `layout.tsx` (zonder NavBar/StatusBar/MinWidthBanner) — een nested layout in `(app)/m/*` zou de NavBar erven. Auth via gedeelde `lib/auth-guard.ts` `requireSession()`. Zie [`docs/architecture/project-structure.md`](../architecture/project-structure.md) voor de volledige architectuur.
|
||||
|
||||
**Acceptatiecriteria:**
|
||||
- [ ] Phone-UA bij login → `/m/products/[active]/solo` (zonder actief product → `/m/settings`)
|
||||
- [ ] Tablet-UA en desktop-UA blijven naar `/dashboard`
|
||||
- [ ] `/m/*` rendert geen NavBar, AppIcon, MinWidthBanner of StatusBar — alleen tab-bar onderaan
|
||||
- [ ] Portrait-modus toont rotate-overlay; landscape verbergt overlay
|
||||
- [ ] PWA-manifest verzoekt `landscape`-orientatie (iOS Safari kan dit niet 100% afdwingen — CSS-overlay als fallback)
|
||||
- [ ] Tab-bar onderaan: Backlog (ListTree), Solo (Activity), Settings — alleen iconen, geen labels, tap-target ≥44×44px
|
||||
- [ ] Backlog op `<1024px` rendert in tab-mode (tabs: PBI's | Stories | Taken) met click-cascade auto-switch
|
||||
- [ ] Entity-dialogen (PBI, Story, Task, Task-detail) renderen full-screen op `<640px` via gedeelde `entityDialogContentClasses`
|
||||
- [ ] Solo-paneel behoudt 3-koloms-kanban met horizontal scroll (geen 1-koloms-mode)
|
||||
- [ ] Settings: account-info read-only, product-selector activeert + redirect, QR-instructie naar desktop, logout met bevestiging
|
||||
- [ ] `/m/pair` (QR-pairing-bevestiging) blijft werken — alleen filesystem-locatie verhuisd, URL onveranderd
|
||||
- [ ] Demo-user op mobile: read-only werkt; logout staat toe
|
||||
|
||||
**Bekende limiet:** iOS Safari respecteert `manifest.orientation` niet altijd in PWA-modus — de CSS-overlay (`<LandscapeGuard>`) is de feitelijke afdwinging.
|
||||
|
||||
---
|
||||
|
||||
## Datamodel (schets)
|
||||
|
||||
| Entiteit | Sleutelvelden | Relaties / opmerkingen |
|
||||
|
|
|
|||
24
lib/auth-guard.ts
Normal file
24
lib/auth-guard.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isPairedSessionExpired } from '@/lib/auth/pairing'
|
||||
|
||||
/**
|
||||
* Layout-side auth guard. Returns the session when valid; otherwise redirects
|
||||
* to /login (and destroys an expired paired-session first).
|
||||
*
|
||||
* Used by both `app/(app)/layout.tsx` (desktop) and `app/(mobile)/layout.tsx`.
|
||||
*/
|
||||
export async function requireSession() {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session.userId) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (isPairedSessionExpired(session)) {
|
||||
await session.destroy()
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
8
lib/user-agent.ts
Normal file
8
lib/user-agent.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// PBI-11 / ST-1135: detecteert telefoon-UA's voor login-redirect.
|
||||
// Heuristiek: 'Mobi' in de UA-string. Zit in Android Chrome en iPhone Safari
|
||||
// Mobile, NIET in iPad of Android-tablet — exact wat we willen voor de
|
||||
// `/m/*`-mobile-shell (alleen telefoons, geen tablets).
|
||||
|
||||
export function isPhoneUA(ua: string | null): boolean {
|
||||
return ua !== null && ua.includes('Mobi')
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"description": "Lichtgewicht Scrum-planner voor solo developers en kleine teams",
|
||||
"start_url": "/dashboard",
|
||||
"display": "standalone",
|
||||
"orientation": "landscape",
|
||||
"background_color": "#0d0a14",
|
||||
"theme_color": "#7c3aed",
|
||||
"icons": [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue