feat: PrimeVue replaced by Nuxt UI using token files from web/src/theme, with AuthShell, DashboardShell, AppBoot and the route guards from routes.md

This commit is contained in:
fsylvain committed 2026-09-25 17:09:34 +02:00
1 parent 24f95a8fd7
commit eed661bb24
38 files changed
+2365 -108

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
Feature: Nuxt UI shell foundation and router guards
As the maintainer of RenewCMS
I want the SPA to run on Nuxt UI instead of PrimeVue, with AuthShell and DashboardShell layouts,
an AppBoot skeleton, and a router guard that resolves and caches the session once per app load
So that every later screen slice can build on a themed, guarded shell instead of an unguarded router
with no layout
Background:
Given web/src/main.ts wires the app through the @nuxt/ui/vite plugin, and no longer imports
primevue, @primeuix/themes, primeicons or tailwindcss-primeui
And web/src/theme/ui.config.ts and web/src/assets/main.css hold the token files copied from
docs/design/source/tokens/ (ui.config.ts, renewcms.css), and public/favicon.svg is the file
copied from docs/design/source/brand/favicon.svg
And the router declares route meta.auth as one of "public", "session", "verified" or "any", and
meta.title for every verified route
And a global router.beforeEach guard runs on every navigation, and on the first navigation of the
app it resolves the session by calling GET /v1/auth/me once and caches the result (session or
"no session") in a session Pinia store; later navigations in the same app load reuse that
cached result instead of calling GET /v1/auth/me again
And /home, /article and /integration currently render only a trivial placeholder view inside
DashboardShell; later screen slices (Home, Articles, Integration) replace that placeholder
content with the real screens
Scenario: A public route never triggers a session check
Given the target route is /login, whose meta.auth is "public"
When the router navigates to it as the first navigation of the app
Then GET /v1/auth/me is not called
And AuthShell renders immediately, with no AppBoot skeleton shown
Scenario: The first navigation to a non-public route resolves the session and shows AppBoot meanwhile
Given the target route is /home, whose meta.auth is "verified"
And GET /v1/auth/me has not resolved yet
When the router navigates to /home as the first navigation of the app
Then DashboardShell renders with the AppBoot skeleton in place of the page content
When GET /v1/auth/me then resolves 200 with is_verified true
Then the AppBoot skeleton is replaced by the routed page's content
And the navigation is not redirected
Scenario: A second navigation reuses the cached session instead of calling GET /v1/auth/me again
Given GET /v1/auth/me has already resolved 200 with is_verified true during the first navigation
When the router navigates from /home to /article, whose meta.auth is also "verified"
Then GET /v1/auth/me is not called a second time
And no AppBoot skeleton is shown for this navigation
Scenario: A verified-only route admits a verified session
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me resolves 200 with is_verified true
When the router navigates to /article
Then the navigation completes to /article
And there is no redirect
Scenario: A verified-only route redirects an unverified session to the pending page
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me resolves 200 with is_verified false
When the router navigates to /article
Then the router redirects to /register/pending
And the navigation to /article does not complete
Scenario: A session-only route admits an unverified session
Given the target route is /register/pending, whose meta.auth is "session"
And GET /v1/auth/me resolves 200 with is_verified false
When the router navigates to /register/pending
Then the navigation completes to /register/pending
And there is no redirect
Scenario: No session redirects to login with the original path and an expired reason
Given the target route is /article/12/edit, whose meta.auth is "verified"
And GET /v1/auth/me resolves 401
When the router navigates to /article/12/edit
Then the router redirects to "/login?redirect=/article/12/edit&reason=expired"
And the navigation to /article/12/edit does not complete
Scenario Outline: The root path resolves to /home, /login or /register/pending depending on the session
Given GET /v1/auth/me resolves <response>
When the router navigates to /
Then the router redirects to <destination>
Examples:
| response | destination |
| 200 with is_verified true | /home |
| 401 | /login |
| 200 with is_verified false | /register/pending |
Scenario: A session check that fails with a server error renders the Generic error view instead of redirecting
Given the target route is /, whose meta.auth is "any"
And GET /v1/auth/me fails with a 500 response
When the router navigates to /
Then the router does not redirect anywhere
And the Generic error view renders in place, with breadcrumb "Home", title "Something went wrong",
text "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening,
restart the RenewCMS server.", a primary action "Try again" and a secondary action "Go to Home"
When "Try again" is activated
Then GET /v1/auth/me is called again to re-run the session check
Scenario: A session check that fails with a network error on a non-root verified route also renders the Generic error view
Given the target route is /article, whose meta.auth is "verified"
And GET /v1/auth/me fails with a network error
When the router navigates to /article
Then the router does not redirect to /login or /register/pending
And DashboardShell renders the Generic error view in place of the AppBoot skeleton and the page
content, with title "Something went wrong" and a primary action "Try again"
When "Try again" is activated
Then GET /v1/auth/me is called again to re-run the session check
Scenario Outline: Each verified placeholder route renders inside DashboardShell with its own heading, breadcrumb and active nav item
Given GET /v1/auth/me resolves 200 with is_verified true
When the router navigates to <path>
Then the route renders inside DashboardShell
And the page heading is <title>
And the breadcrumb is <title>
And the nav item for <title> has aria-current="page"
Examples:
| path | title |
| /home | "Home" |
| /article | "Articles" |
| /integration | "Integration" |
Scenario: AuthShell renders its title as the page heading and sets the document title
Given a screen renders AuthShell with title "Log in" and no description
When AuthShell mounts
Then AuthShell renders an h1 containing "Log in"
And document.title is "Log in · RenewCMS"
Scenario: AuthShell renders its notice and footer slots
Given a screen renders AuthShell with a "notice" slot containing "Your session expired." and a
"footer" slot containing a "Forgot password?" link
When AuthShell mounts
Then the alert above the title contains "Your session expired."
And the footer area outside the card contains a "Forgot password?" link
Scenario: DashboardShell renders the three navigation destinations
Given the current route is /article
When DashboardShell mounts
Then the sidebar navigation lists exactly three destinations: "Home" linking to /home, "Articles"
linking to /article, and "Integration" linking to /integration
And the "Articles" destination has aria-current="page"
And neither "Home" nor "Integration" has aria-current set
Scenario: DashboardShell shows the offline banner when the browser reports offline
Given navigator.onLine is false
When DashboardShell mounts
Then an offline banner is shown
When navigator.onLine becomes true
Then the offline banner is hidden
+73
View File
@@ -0,0 +1,73 @@
# QA: Nuxt UI shell foundation and router guards
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest server.
1. **Public route skips session check** — mount the router at /login; assert the mocked
GET /v1/auth/me function is never invoked, and AuthShell is rendered with no AppBoot skeleton.
2. **AppBoot shown while GET /v1/auth/me is pending** — mount the router at /home with an
unresolved GET /v1/auth/me promise; assert the AppBoot skeleton (USkeleton) is present and the
route's own content is absent; resolve the promise with 200 is_verified true; assert the skeleton
is gone and the route's content is present.
3. **Session cached across navigations** — resolve GET /v1/auth/me once (200 is_verified true),
navigate /home → /article; assert the mock was called exactly once.
4. **Verified route + verified session** — mock 200 is_verified true, navigate to /article; assert
the router's current route is /article (no redirect).
5. **Verified route + unverified session** — mock 200 is_verified false, navigate to /article; assert
the router redirects to /register/pending.
6. **Session route + unverified session** — mock 200 is_verified false, navigate to
/register/pending; assert no redirect.
7. **No session** — mock a 401, navigate to /article/12/edit; assert redirect to
/login?redirect=/article/12/edit&reason=expired.
8. **Root path outcomes** — one test per Examples row: 200 is_verified true → /home, 401 → /login,
200 is_verified false → /register/pending.
9. **Boot failure at root renders the Generic error view** — mock GET /v1/auth/me rejecting with a
500, navigate to /; assert no redirect happens, and the Generic error view renders with title
"Something went wrong" and text "This page couldn't load. Your saved work is safe. Try again, and
if it keeps happening, restart the RenewCMS server."; click "Try again"; assert
GET /v1/auth/me is called a second time.
10. **Boot failure at a non-root verified route renders the Generic error view** — mock
GET /v1/auth/me rejecting with a network error, navigate to /article; assert no redirect to
/login or /register/pending happens, DashboardShell shows the Generic error view instead of
AppBoot or page content; click "Try again"; assert GET /v1/auth/me is called a second time.
11. **Placeholder verified routes wire up shell, heading, breadcrumb and active nav** — one test per
Examples row (/home → "Home", /article → "Articles", /integration → "Integration"): mock 200
is_verified true, navigate to the path; assert the route renders inside DashboardShell, the page
heading and breadcrumb both equal the expected title, and the corresponding nav item has
aria-current="page" while the other two do not.
12. **AuthShell renders title as h1 and sets document.title** — mount AuthShell with
title="Log in"; assert an h1 with text "Log in" and document.title === "Log in · RenewCMS".
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
both render outside/above the card content as specified.
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
3 links (Home→/home, Articles→/article, Integration→/integration), and only the Articles link
has aria-current="page".
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
it hides.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Back-office.dc.html`, groups "Boot" and "Global",
plus the components.md AuthShell/DashboardShell sections.
- [ ] AuthShell at desktop (sm+): 400px UPageCard variant="outline", centered, top offset 12vh,
light and dark.
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
light and dark.
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 3 nav items, UserMenu footer;
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
slideover, closes after navigating.
- [ ] DashboardShell active nav item uses the sand-fill/ink-text bend, not Nuxt UI's default
primary-tinted background.
- [ ] AppBoot skeleton visual match to the boot board, light and dark.
- [ ] Generic error view visual match to the Global board's "Generic error" state, desktop and
mobile, light and dark (breadcrumb "Home", title "Something went wrong", primary "Try again",
secondary "Go to Home").
- [ ] Keyboard: first Tab stop on a DashboardShell route is the "Skip to content" link; sidebar is
a `nav` labeled "Main"; collapse button exposes `aria-expanded`; AuthShell moves focus to its
h1 on route change; the Generic error view's title gets focus on render.
- [ ] Favicon renders as the copied favicon.svg mark in the browser tab, dark-aware.
+3
View File
@@ -28,6 +28,9 @@ coverage
.eslintcache
/auto-imports.d.ts
/components.d.ts
# Cypress
/cypress/videos/
/cypress/screenshots/
+641 -22
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RenewCMS</title>
</head>
+1 -4
View File
@@ -16,13 +16,10 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@primeuix/themes": "^2.0.2",
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
"tailwindcss": "^4.1.18",
"tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplOTQ3NWYzYi1iMzk1LTQwYTItYTc0NC02OTBiNWNhN2ExYmEAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaBZb8xjia8MHL36GYqgFh9IAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDphMzk4M2EzMy03MTA2LTQwODctYjI1Yy05ZDBjMDgyNTBhNWNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoZLnQVKFQmfShOH339JG7cQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggm30c/9ae4hGpd7ZqF9l6g7kujDirGaXyTi536jB+bcekZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaLY9Kv67+CqoKTGwY2CZrToAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCB5yyMJKOtdrWszMUivonxA1KFo3L7r2RCA4Mx1H0/3uWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiYZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmU5NDc1ZjNiLWIzOTUtNDBhMi1hNzQ0LTY5MGI1Y2E3YTFiYS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjViZTQwMjZjLWM3ZjctNDUzNC1hYmFiLTY3OTc0OWZjNGFkY3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCbfRz/1p7iEal3tmoX2XqDuS6MOKsZpfJOLnfqMH5tx6JjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggM1R3FsRboXyA4rm7pAVOcC/HIi2cgqmrpa/mZrSD7n6iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggoD1kSgBM7/QspOsFvy5FPeDatbpPPR1Z+79+Ox5QvZZ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated

After

Width:  |  Height:  |  Size: 7.9 KiB

+3 -7
View File
@@ -1,13 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import NavComponent from './components/NavComponent.vue'
</script>
<template>
<nav-component />
<main>
<router-view />
</main>
<UApp class="isolate">
<RouterView />
</UApp>
</template>
<style scoped></style>
+327 -7
View File
@@ -1,14 +1,334 @@
@import "tailwindcss";
@import "tailwindcss-primeui";
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap');
@import 'tailwindcss';
@import '@nuxt/ui';
* {
box-sizing: border-box;
@theme static {
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Source Serif 4', ui-serif, Georgia, serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
--color-ink-50: #fafafa;
--color-ink-100: #f2f2f2;
--color-ink-200: #e5e5e5;
--color-ink-300: #cccccc;
--color-ink-400: #999999;
--color-ink-500: #666666;
--color-ink-600: #444444;
--color-ink-700: #333333;
--color-ink-800: #222222;
--color-ink-900: #111111;
--color-ink-950: #0a0a0a;
--color-chalk-50: #fafafa;
--color-chalk-100: #f3f2f0;
--color-chalk-200: #e6e4e0;
--color-chalk-300: #cfccc7;
--color-chalk-400: #999999;
--color-chalk-450: #8a8783;
--color-chalk-500: #666666;
--color-chalk-600: #444444;
--color-chalk-700: #333333;
--color-chalk-800: #222222;
--color-chalk-850: #1a1a1a;
--color-chalk-900: #111111;
--color-chalk-950: #0a0a0a;
--color-sand-50: #f7f4ef;
--color-sand-100: #f0eae1;
--color-sand-200: #e8dfd1;
--color-sand-300: #d4c5b0;
--color-sand-400: #bba88e;
--color-sand-500: #9c8a70;
--color-sand-600: #7d6d56;
--color-sand-700: #635643;
--color-sand-800: #4a4033;
--color-sand-900: #322b23;
--color-sand-950: #211c17;
--color-moss-50: #f0faf0;
--color-moss-100: #e1f3e1;
--color-moss-200: #c7e2c7;
--color-moss-300: #a3caa3;
--color-moss-400: #7fac7f;
--color-moss-500: #5f8e60;
--color-moss-600: #457347;
--color-moss-700: #315b33;
--color-moss-800: #1f4422;
--color-moss-900: #143015;
--color-moss-950: #081e0a;
--color-slate-50: #f0f8ff;
--color-slate-100: #e2effa;
--color-slate-200: #c8dcee;
--color-slate-300: #a5c2db;
--color-slate-400: #81a3c0;
--color-slate-500: #6284a2;
--color-slate-600: #486a86;
--color-slate-700: #34536d;
--color-slate-800: #223d53;
--color-slate-900: #162b3c;
--color-slate-950: #0a1a26;
--color-ochre-50: #fff5e8;
--color-ochre-100: #fdead1;
--color-ochre-200: #f1d4ad;
--color-ochre-300: #dfb67d;
--color-ochre-400: #c4954f;
--color-ochre-500: #a67628;
--color-ochre-600: #8a5b00;
--color-ochre-700: #6c4700;
--color-ochre-800: #503400;
--color-ochre-900: #3a2400;
--color-ochre-950: #241500;
--color-brick-50: #fef4f2;
--color-brick-100: #ffe7e2;
--color-brick-200: #ffcbc2;
--color-brick-300: #fba394;
--color-brick-400: #e37e6e;
--color-brick-500: #c45d4e;
--color-brick-600: #a54134;
--color-brick-700: #882c21;
--color-brick-800: #6a1a11;
--color-brick-900: #4d0f08;
--color-brick-950: #330503;
--text-xs: 0.75rem;
--text-xs--line-height: 16px;
--text-sm: 0.8125rem;
--text-sm--line-height: 20px;
--text-base: 0.875rem;
--text-base--line-height: 22px;
--text-lg: 1rem;
--text-lg--line-height: 24px;
--text-xl: 1.25rem;
--text-xl--line-height: 28px;
--text-2xl: 1.75rem;
--text-2xl--line-height: 36px;
--breakpoint-xs: 22.5rem;
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-enter: cubic-bezier(0, 0, 0, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
}
:root {
--ui-radius: 0rem;
--ui-header-height: 3rem;
--ui-container: 72rem;
--duration-fast: 120ms;
--duration-base: 180ms;
--duration-slow: 240ms;
--z-sticky: 10;
--z-sidebar: 20;
--z-banner: 30;
--z-popover: 40;
--z-overlay: 50;
--z-toast: 60;
--z-tooltip: 70;
--ui-bg: var(--color-white);
--ui-bg-muted: var(--color-chalk-50);
--ui-bg-elevated: var(--color-chalk-100);
--ui-bg-accented: var(--color-chalk-200);
--ui-bg-inverted: var(--color-chalk-900);
--ui-text-dimmed: var(--color-chalk-400);
--ui-text-muted: var(--color-chalk-500);
--ui-text-toned: var(--color-chalk-600);
--ui-text: var(--color-chalk-800);
--ui-text-highlighted: var(--color-chalk-900);
--ui-text-inverted: var(--color-white);
--ui-border: var(--color-chalk-200);
--ui-border-muted: var(--color-chalk-100);
--ui-border-accented: var(--color-chalk-450);
--ui-border-inverted: var(--color-chalk-900);
--ui-primary: var(--ui-color-primary-900);
--ui-secondary: var(--ui-color-secondary-700);
--ui-success: var(--ui-color-success-700);
--ui-info: var(--ui-color-info-700);
--ui-warning: var(--ui-color-warning-700);
--ui-error: var(--ui-color-error-700);
}
.dark {
--ui-bg: var(--color-chalk-900);
--ui-bg-muted: var(--color-chalk-950);
--ui-bg-elevated: var(--color-chalk-850);
--ui-bg-accented: var(--color-chalk-800);
--ui-bg-inverted: var(--color-chalk-50);
--ui-text-dimmed: var(--color-chalk-500);
--ui-text-muted: var(--color-chalk-400);
--ui-text-toned: var(--color-chalk-300);
--ui-text: var(--color-chalk-200);
--ui-text-highlighted: var(--color-chalk-50);
--ui-text-inverted: var(--color-chalk-900);
--ui-border: var(--color-chalk-700);
--ui-border-muted: var(--color-chalk-800);
--ui-border-accented: var(--color-chalk-500);
--ui-border-inverted: var(--color-chalk-50);
--ui-primary: var(--ui-color-primary-50);
--ui-secondary: var(--ui-color-secondary-300);
--ui-success: var(--ui-color-success-300);
--ui-info: var(--ui-color-info-300);
--ui-warning: var(--ui-color-warning-300);
--ui-error: var(--ui-color-error-300);
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
}
body {
margin: 0;
background: var(--ui-bg-muted);
color: var(--ui-text);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
-webkit-font-smoothing: antialiased;
}
.button-position {
@apply fixed bottom-0 left-0 m-3;
.article-content {
font-family: var(--font-serif);
font-size: 1.0625rem;
line-height: 1.75rem;
color: var(--ui-text-highlighted);
max-width: 42rem;
overflow-wrap: break-word;
}
.article-content > :first-child {
margin-top: 0;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4 {
font-weight: 600;
text-wrap: balance;
color: var(--ui-text-highlighted);
}
.article-content h1 {
font-size: 1.75rem;
line-height: 2.25rem;
margin: 0 0 1rem;
}
.article-content h2 {
font-size: 1.4375rem;
line-height: 1.9375rem;
margin: 2.5rem 0 0.75rem;
}
.article-content h3 {
font-size: 1.1875rem;
line-height: 1.75rem;
margin: 2rem 0 0.5rem;
}
.article-content h4 {
font-size: 1.0625rem;
line-height: 1.625rem;
margin: 1.5rem 0 0.5rem;
}
.article-content p {
font-size: 1.0625rem;
line-height: 1.75rem;
margin: 0 0 1.25rem;
}
.article-content ul,
.article-content ol {
margin: 0 0 1.25rem;
padding-left: 1.75rem;
}
.article-content ul {
list-style: square;
}
.article-content ol {
list-style: decimal;
}
.article-content li {
margin: 0 0 0.375rem;
}
.article-content li::marker {
color: var(--ui-text-muted);
}
.article-content blockquote {
margin: 1.75rem 0;
padding-left: 1.25rem;
border-left: 2px solid var(--ui-primary);
font-style: italic;
color: var(--ui-text-toned);
}
.article-content a {
color: var(--ui-text-highlighted);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.article-content a:hover {
text-decoration-thickness: 2px;
}
.article-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1.75rem 0;
}
.article-content figure {
margin: 1.75rem 0;
}
.article-content figure img {
margin: 0;
}
.article-content figcaption {
margin-top: 0.5rem;
font-family: var(--font-sans);
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--ui-text-muted);
}
@media (min-width: 48rem) {
.article-content h1 {
font-size: 2rem;
line-height: 2.5rem;
}
.article-content h2 {
font-size: 1.625rem;
line-height: 2.125rem;
}
.article-content h3 {
font-size: 1.3125rem;
line-height: 1.875rem;
}
.article-content h4 {
font-size: 1.125rem;
line-height: 1.75rem;
}
.article-content p {
font-size: 1.125rem;
line-height: 1.875rem;
}
.article-content {
font-size: 1.125rem;
line-height: 1.875rem;
}
.article-content figcaption {
font-size: 0.875rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.animate-spin {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<div class="space-y-4 p-6" role="status" aria-label="Loading">
<USkeleton class="h-6 w-40" />
<USkeleton class="h-32 w-full" />
<USkeleton class="h-32 w-full" />
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useSlots, watch } from 'vue'
const props = defineProps<{
title: string
description?: string
}>()
const slots = useSlots()
watch(
() => props.title,
(title) => {
document.title = `${title} · RenewCMS`
},
{ immediate: true },
)
</script>
<template>
<main class="flex min-h-screen flex-col items-center bg-default px-5 pt-[12vh] sm:px-0">
<UAlert v-if="slots.notice" class="mb-4 w-full sm:max-w-[400px]">
<template #description>
<slot name="notice" />
</template>
</UAlert>
<UPageCard variant="outline" class="w-full sm:max-w-[400px]">
<span role="img" aria-label="RenewCMS" class="i-lucide-square inline-block h-8 w-8" />
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
<slot />
</UPageCard>
<footer class="mt-4 flex w-full items-center justify-between sm:max-w-[400px]">
<slot name="footer" />
<UColorModeSelect size="sm" />
</footer>
</main>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import AppBoot from '@/components/AppBoot.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
const route = useRoute()
const session = useSessionStore()
const navItems = [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
]
const pageTitle = computed(() => (route.meta.title as string) ?? '')
function onRetry() {
session.retry()
}
</script>
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4">
<template #header>
<span class="font-semibold">RenewCMS</span>
</template>
<UNavigationMenu orientation="vertical" :items="navItems" aria-label="Main" />
<template #footer>
<span>Log out</span>
</template>
</UDashboardSidebar>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<slot name="navbar-right" />
</template>
</UDashboardNavbar>
<UBreadcrumb :items="[{ label: pageTitle }]" />
</template>
<template #body>
<div id="dashboard-content">
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
<ErrorView
v-else-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<div v-else>
<h1>{{ pageTitle }}</h1>
<slot />
</div>
</div>
</template>
</UDashboardPanel>
</UDashboardGroup>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
kind: 'not-found' | 'error'
breadcrumb?: string
}>(),
{
breadcrumb: 'Home',
},
)
const emit = defineEmits<{
retry: []
}>()
const router = useRouter()
const copy = computed(() =>
props.kind === 'not-found'
? {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
}
: {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
},
)
function onPrimary() {
if (props.kind === 'error') {
emit('retry')
return
}
router.push('/home')
}
function onSecondary() {
if (props.kind === 'error') {
router.push('/home')
return
}
router.push('/article')
}
</script>
<template>
<div role="alert" class="space-y-4 p-6">
<nav aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
</nav>
<h1 tabindex="-1">{{ copy.title }}</h1>
<p>{{ copy.text }}</p>
<div class="flex gap-2">
<UButton @click="onPrimary">{{ copy.primary }}</UButton>
<UButton variant="outline" color="neutral" @click="onSecondary">{{
copy.secondary
}}</UButton>
</div>
</div>
</template>
-33
View File
@@ -1,33 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Drawer } from 'primevue'
import { RouterLink } from 'vue-router'
const visible = ref(false)
</script>
<template>
<header>
<Drawer v-model:visible="visible">
<template #container="{ closeCallback }">
<ul @click="closeCallback">
<li><RouterLink to="/">Home</RouterLink></li>
<li><RouterLink to="/article">Article</RouterLink></li>
</ul>
<div class="button-position">
<Button
type="button"
@click="closeCallback"
icon="pi pi-times"
variant="outlined"
></Button>
</div>
</template>
</Drawer>
</header>
<div class="button-position">
<Button icon="pi pi-bars" @click="visible = true" />
</div>
</template>
<style scoped></style>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const isOffline = ref(!navigator.onLine)
function updateOnlineStatus() {
isOffline.value = !navigator.onLine
}
onMounted(() => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
</script>
<template>
<UBanner
v-if="isOffline"
color="neutral"
title="You're offline. You can keep writing, but saving and publishing will wait until you're back online."
/>
</template>
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import AuthShell from '../AuthShell.vue'
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
return mount(AuthShell, {
props,
slots,
global: {
plugins: [uiPlugin],
},
})
}
describe('AuthShell renders its title as the page heading and sets the document title', () => {
it('renders an h1 containing the title and sets document.title', () => {
const wrapper = mountShell({ title: 'Log in' })
expect(wrapper.find('h1').text()).toBe('Log in')
expect(document.title).toBe('Log in · RenewCMS')
})
})
describe('AuthShell renders its notice and footer slots', () => {
it('renders the notice slot in an alert above the title and the footer slot outside the card', () => {
const wrapper = mountShell(
{ title: 'Log in' },
{
notice: '<span>Your session expired.</span>',
footer: '<a href="/register/reset/request">Forgot password?</a>',
},
)
const alertDescription = wrapper.find('[data-slot="description"]')
expect(alertDescription.text()).toBe('Your session expired.')
const html = wrapper.html()
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1>'))
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
expect(footerLink.text()).toBe('Forgot password?')
})
})
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
async function mountShell(path: string) {
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true }) })
await router.push(path)
const wrapper = mount(DashboardShell, {
global: {
plugins: [router, uiPlugin],
},
})
await flushPromises()
return wrapper
}
afterEach(async () => {
await router.push('/login')
})
describe('DashboardShell renders the three navigation destinations', () => {
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
const integration = wrapper.find('a[href="/integration"]')
expect(home.exists()).toBe(true)
expect(articles.exists()).toBe(true)
expect(integration.exists()).toBe(true)
expect(articles.attributes('aria-current')).toBe('page')
expect(home.attributes('aria-current')).toBeUndefined()
expect(integration.attributes('aria-current')).toBeUndefined()
})
})
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
it('shows the banner while offline and hides it once back online', async () => {
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
const wrapper = await mountShell('/article')
expect(wrapper.text()).toContain("You're offline")
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(wrapper.text()).not.toContain("You're offline")
onLineSpy.mockRestore()
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import ErrorView from '../ErrorView.vue'
function makeRouter() {
return createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/home', component: { template: '<div />' } },
{ path: '/article', component: { template: '<div />' } },
],
})
}
async function mountNotFound(breadcrumb?: string) {
const router = makeRouter()
await router.push('/')
return mount(ErrorView, {
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
global: {
plugins: [router],
},
})
}
describe('ErrorView not-found kind', () => {
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
const wrapper = await mountNotFound('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe('Not found')
expect(wrapper.find('h1').text()).toBe('Page not found')
expect(wrapper.text()).toContain('The link may be mistyped, or the page may have moved.')
const buttons = wrapper.findAll('button')
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
})
it('routes the primary action to /home and the secondary action to /article', async () => {
const wrapper = await mountNotFound()
const router = wrapper.vm.$router
const primary = wrapper.findAll('button').find((button) => button.text() === 'Go to Home')
await primary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/home')
const secondary = wrapper
.findAll('button')
.find((button) => button.text() === 'All articles')
await secondary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
})
+25
View File
@@ -0,0 +1,25 @@
export class ApiError extends Error {
status: number
constructor(status: number) {
super(`Request failed with status ${status}`)
this.status = status
}
}
export interface MeResult {
verified: boolean
}
export function useApi() {
async function getMe(): Promise<MeResult> {
const response = await fetch('/v1/auth/me', { credentials: 'include' })
if (!response.ok) {
throw new ApiError(response.status)
}
const body = await response.json()
return { verified: Boolean(body.is_verified) }
}
return { getMe }
}
+2 -10
View File
@@ -1,24 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import ui from '@nuxt/ui/vue-plugin'
import App from './App.vue'
import router from './router'
import 'primeicons/primeicons.css'
import '@/assets/main.css'
const app = createApp(App)
app.use(PrimeVue, {
theme: {
preset: Aura,
},
})
app.use(createPinia())
app.use(ui)
app.use(router)
app.mount('#app')
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { defineComponent, h } from 'vue'
import { RouterView } from 'vue-router'
import router from '@/router'
import { useApi, ApiError } from '@/composables/useApi'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const RootComponent = defineComponent({
setup() {
return () => h(RouterView)
},
})
let wrapper: VueWrapper | null = null
function setUpSession() {
const pinia = createPinia()
setActivePinia(pinia)
}
function mountApp() {
wrapper = mount(RootComponent, {
global: {
plugins: [router, uiPlugin],
},
})
return wrapper
}
beforeEach(() => {
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
})
describe('Nuxt UI shell foundation and router guards', () => {
it('A public route never triggers a session check', async () => {
const getMe = vi.fn()
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/login')
const app = mountApp()
await flushPromises()
expect(getMe).not.toHaveBeenCalled()
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
expect(app.find('h1').text()).toBe('Log in')
})
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
let resolveMe: (value: { verified: boolean }) => void = () => {}
const getMe = vi.fn(
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
)
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/home')
const app = mountApp()
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(true)
expect(app.text()).not.toContain('Home placeholder')
resolveMe({ verified: true })
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
expect(app.text()).toContain('Home placeholder')
expect(router.currentRoute.value.path).toBe('/home')
})
it('A second navigation reuses the cached session instead of calling GET /v1/auth/me again', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/home')
const app = mountApp()
await flushPromises()
await router.push('/article')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(1)
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
})
it('A verified-only route admits a verified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
it('A verified-only route redirects an unverified session to the pending page', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('A session-only route admits an unverified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/register/pending')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
it('No session redirects to login with the original path and an expired reason', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article/12/edit')
mountApp()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/login?redirect=/article/12/edit&reason=expired',
)
})
it.each([
{ verified: true, destination: '/home' },
{ verified: false, destination: '/register/pending' },
])(
'The root path resolves depending on the session (verified $verified)',
async ({ verified, destination }) => {
const getMe = vi.fn().mockResolvedValue({ verified })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe(destination)
},
)
it('The root path resolves to /login when there is no session', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
})
it('A session check that fails with a server error renders the Generic error view instead of redirecting', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(500))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
const app = mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
expect(app.find('h1').text()).toBe('Something went wrong')
expect(app.text()).toContain(
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
)
expect(app.text()).toContain('Home')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(2)
})
it('A session check that fails with a network error on a non-root verified route also renders the Generic error view', async () => {
const getMe = vi.fn().mockRejectedValue(new TypeError('network error'))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
const app = mountApp()
await vi.waitFor(() =>
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
)
expect(router.currentRoute.value.path).toBe('/article')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(2)
})
it.each([
{ path: '/home', title: 'Home' },
{ path: '/article', title: 'Articles' },
{ path: '/integration', title: 'Integration' },
])(
'Placeholder verified route $path renders inside DashboardShell with title $title',
async ({ path, title }) => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push(path)
const app = mountApp()
await flushPromises()
const headings = app.findAll('h1')
expect(headings.some((heading) => heading.text() === title)).toBe(true)
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
expect(activeLink.exists()).toBe(true)
},
)
})
+143 -3
View File
@@ -1,11 +1,151 @@
import { createRouter, createWebHistory } from 'vue-router'
import {
createRouter,
createWebHistory,
type RouteLocationNormalized,
type RouteLocationRaw,
} from 'vue-router'
import { useSessionStore } from '@/stores/session'
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: () => import('@/views/HomeView.vue') },
{ path: '/article', component: () => import('@/views/ArticleView.vue') },
{ path: '/', component: () => import('@/views/RootView.vue'), meta: { auth: 'any' } },
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
meta: { auth: 'public' },
},
{
path: '/register',
component: () => import('@/views/RegisterView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/pending',
component: () => import('@/views/PendingView.vue'),
meta: { auth: 'session' },
},
{
path: '/register/validate',
component: () => import('@/views/VerifyView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/request',
component: () => import('@/views/ResetRequestView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/validate',
component: () => import('@/views/ResetConfirmView.vue'),
meta: { auth: 'public' },
},
{
path: '/home',
component: () => import('@/views/HomeView.vue'),
meta: { auth: 'verified', title: 'Home' },
},
{
path: '/article',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/new',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/:id/edit',
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
meta: { auth: 'any' },
},
],
})
export function resolveAuthRedirect(
to: RouteLocationNormalized,
session: ReturnType<typeof useSessionStore>,
): RouteLocationRaw | true {
const auth = to.meta.auth as AuthRequirement
if (auth === 'public') {
return true
}
if (session.status !== 'ready') {
return true
}
const account = session.session
if (!account) {
if (auth === 'any') {
return { path: '/login' }
}
return { path: '/login', query: { redirect: to.fullPath, reason: 'expired' } }
}
if (!account.verified) {
if (auth === 'verified' || auth === 'any') {
return { path: '/register/pending' }
}
return true
}
if (auth === 'any') {
return { path: '/home' }
}
return true
}
router.beforeEach((to) => {
if (to.meta.auth === 'public') {
return true
}
const session = useSessionStore()
if (session.status === 'idle') {
session.check()
return true
}
if (session.status === 'checking') {
return true
}
return resolveAuthRedirect(to, session)
})
router.afterEach((to) => {
if (to.meta.auth === 'public') {
return
}
const session = useSessionStore()
const settled =
session.status === 'idle' || session.status === 'checking'
? session.check()
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(router.currentRoute.value, session)
if (destination !== true) {
router.replace(destination)
}
})
})
export default router
+50
View File
@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
export interface Session {
verified: boolean
}
export const useSessionStore = defineStore('session', () => {
const status = ref<SessionStatus>('idle')
const session = ref<Session | null>(null)
let pending: Promise<void> | null = null
function check() {
if (pending) {
return pending
}
status.value = 'checking'
const { getMe } = useApi()
pending = getMe()
.then((result) => {
session.value = result
status.value = 'ready'
})
.catch((error) => {
if (error instanceof ApiError && error.status === 401) {
session.value = null
status.value = 'ready'
return
}
status.value = 'error'
})
.finally(() => {
pending = null
})
return pending
}
function retry() {
status.value = 'idle'
return check()
}
return { status, session, check, retry }
})
+183
View File
@@ -0,0 +1,183 @@
export const renewUi = {
colors: {
primary: 'ink',
secondary: 'sand',
success: 'moss',
info: 'slate',
warning: 'ochre',
error: 'brick',
neutral: 'chalk',
},
icons: {
loading: 'i-lucide-loader-circle',
close: 'i-lucide-x',
check: 'i-lucide-check',
search: 'i-lucide-search',
chevronDown: 'i-lucide-chevron-down',
chevronRight: 'i-lucide-chevron-right',
arrowLeft: 'i-lucide-arrow-left',
copy: 'i-lucide-copy',
copyCheck: 'i-lucide-check',
dark: 'i-lucide-moon',
light: 'i-lucide-sun',
system: 'i-lucide-monitor',
success: 'i-lucide-circle-check',
info: 'i-lucide-info',
warning: 'i-lucide-triangle-alert',
error: 'i-lucide-circle-x',
menu: 'i-lucide-menu',
panelClose: 'i-lucide-panel-left-close',
panelOpen: 'i-lucide-panel-left-open',
ellipsis: 'i-lucide-ellipsis',
upload: 'i-lucide-upload',
eye: 'i-lucide-eye',
eyeOff: 'i-lucide-eye-off',
},
button: {
slots: {
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)',
},
defaultVariants: {
size: 'md',
},
},
input: {
slots: {
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted',
},
variants: {
size: {
md: {
base: 'text-base md:text-sm',
},
},
},
},
textarea: {
slots: {
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted text-base md:text-sm',
},
},
select: {
slots: {
base: 'rounded-none ring-(--ui-border-accented)',
content: 'rounded-none shadow-(--shadow-popover)',
},
},
checkbox: {
slots: {
base: 'rounded-none ring-(--ui-border-accented)',
},
},
switch: {
slots: {
base: 'rounded-none',
thumb: 'rounded-none',
},
},
avatar: {
slots: {
root: 'rounded-none bg-(--ui-color-secondary-300)',
fallback: 'text-(--ui-color-primary-900) font-semibold',
},
},
chip: {
slots: {
base: 'rounded-none',
},
},
progress: {
slots: {
base: 'rounded-none',
indicator: 'rounded-none',
},
},
badge: {
slots: {
base: 'rounded-none font-medium',
},
},
kbd: {
base: 'rounded-none font-mono',
},
tabs: {
slots: {
list: 'rounded-none',
trigger: 'rounded-none',
indicator: 'rounded-none',
},
},
dropdownMenu: {
slots: {
content: 'rounded-none shadow-(--shadow-popover)',
item: 'rounded-none',
},
},
tooltip: {
slots: {
content: 'rounded-none bg-inverted text-inverted ring-0',
},
},
modal: {
slots: {
content: 'rounded-none shadow-(--shadow-modal) divide-default',
overlay: 'bg-(--ui-color-neutral-950)/40',
},
},
slideover: {
slots: {
content: 'rounded-none shadow-(--shadow-modal)',
overlay: 'bg-(--ui-color-neutral-950)/40',
},
},
toast: {
slots: {
root: 'rounded-none shadow-(--shadow-popover)',
progress: 'rounded-none',
},
},
toaster: {
slots: {
viewport: 'z-(--z-toast)',
},
},
alert: {
slots: {
root: 'rounded-none',
},
},
card: {
slots: {
root: 'rounded-none',
},
},
table: {
slots: {
thead: 'bg-elevated',
th: 'text-muted font-medium py-2',
td: 'py-3',
tr: 'data-[selectable=true]:hover:bg-elevated',
},
},
navigationMenu: {
compoundVariants: [
{
orientation: 'vertical',
active: true,
class: {
link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) before:hidden',
},
},
],
},
dashboardSidebar: {
slots: {
root: 'bg-default',
},
},
dashboardPanel: {
slots: {
body: 'bg-muted',
},
},
}
+6 -4
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<p>Article</p>
<DashboardShell>
<p>Articles placeholder.</p>
</DashboardShell>
</template>
<style scoped></style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<DashboardShell>
<p>Editor placeholder.</p>
</DashboardShell>
</template>
+6 -4
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<p>Home</p>
<DashboardShell>
<p>Home placeholder.</p>
</DashboardShell>
</template>
<style scoped></style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<DashboardShell>
<p>Integration placeholder.</p>
</DashboardShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Log in">
<p>Log in placeholder.</p>
</AuthShell>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useSessionStore } from '@/stores/session'
import DashboardShell from '@/components/DashboardShell.vue'
import AuthShell from '@/components/AuthShell.vue'
import ErrorView from '@/components/ErrorView.vue'
const session = useSessionStore()
</script>
<template>
<DashboardShell v-if="session.session">
<ErrorView kind="not-found" breadcrumb="Not found" />
</DashboardShell>
<AuthShell v-else title="Page not found">
<ErrorView kind="not-found" breadcrumb="Not found" />
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Check your inbox">
<p>Pending verification placeholder.</p>
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Create your account">
<p>Register placeholder.</p>
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Choose a new password">
<p>Reset confirm placeholder.</p>
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Reset your password">
<p>Reset request placeholder.</p>
</AuthShell>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { useSessionStore } from '@/stores/session'
import ErrorView from '@/components/ErrorView.vue'
const session = useSessionStore()
function onRetry() {
session.retry()
}
</script>
<template>
<ErrorView v-if="session.status === 'error'" kind="error" @retry="onRetry" />
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Verifying your account">
<p>Verify placeholder.</p>
</AuthShell>
</template>
-10
View File
@@ -1,10 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HomeView from '../HomeView.vue'
describe('HomeView', () => {
it('renders a paragraph with the text Home', () => {
const wrapper = mount(HomeView)
expect(wrapper.text()).toContain('Home')
})
})
+13 -3
View File
@@ -3,9 +3,9 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
import ui from '@nuxt/ui/vite'
import { renewUi } from './src/theme/ui.config'
// https://vite.dev/config/
export default defineConfig({
build: {
outDir: '../internal/infrastructure/assets/dist/',
@@ -17,7 +17,17 @@ export default defineConfig({
'/healthz': 'http://localhost:8080',
},
},
plugins: [vue(), vueDevTools(), tailwindcss()],
plugins: [
vue(),
vueDevTools(),
ui({
ui: renewUi,
colorMode: true,
theme: {
colors: ['primary', 'secondary', 'success', 'info', 'warning', 'error'],
},
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),