chore: improve tooling, linting, testing, and CI
- Add LICENSE file (CC BY-NC-SA 4.0) - Add .nvmrc pinning Node 22 - Add Renovate config with daily schedule and Gitea Actions workflow - Reduce ESLint max-warnings from 1000 to 50 - Re-enable Biome a11y/security recommended rules - Fix Biome lint issues (a11y, hook deps, SVG labels, checkbox semantics) - Improve CI: run on pushes to feat/fix branches, add pnpm audit - Add Vitest coverage with v8 provider and thresholds - Add E2E tests (auth, admin, navigation specs) - Add admin-maintenance server action test - Install @vitest/coverage-v8 - Ignore coverage/ directory
This commit is contained in:
1 parent
0f5c190ab1
commit
423a33200e
28 files changed
+1016
-80
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "feat/**"
|
||||||
|
- "fix/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -8,7 +13,7 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: shell
|
runs-on: shell
|
||||||
steps:
|
steps:
|
||||||
- name: Typecheck, lint, and test
|
- name: Typecheck, lint, security audit, and test
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
|
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
|
||||||
@@ -35,6 +40,7 @@ jobs:
|
|||||||
export BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
pnpm audit --audit-level=high || echo "WARNING: pnpm audit found high/critical vulnerabilities"
|
||||||
pnpm biome:lint
|
pnpm biome:lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Renovate
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 5 * * *" # every day at 05:00
|
||||||
|
workflow_dispatch: # manual trigger
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: shell
|
||||||
|
steps:
|
||||||
|
- name: Self-hosted Renovate
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker run --rm \
|
||||||
|
-e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \
|
||||||
|
-e RENOVATE_AUTODISCOVER=false \
|
||||||
|
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
|
||||||
|
-e RENOVATE_ONBOARDING=false \
|
||||||
|
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||||
|
-e LOG_LEVEL=info \
|
||||||
|
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||||
|
ghcr.io/renovatebot/renovate:latest
|
||||||
@@ -24,3 +24,6 @@ gitea
|
|||||||
|
|
||||||
# Runtime uploaded media (persistent, outside public/)
|
# Runtime uploaded media (persistent, outside public/)
|
||||||
storage/
|
storage/
|
||||||
|
|
||||||
|
# Test coverage reports
|
||||||
|
coverage/
|
||||||
+1
-4
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": ["**", "!setup", "!public", "!*.cjs"]
|
"includes": ["**", "!setup", "!*.cjs", "!coverage"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -23,9 +23,6 @@
|
|||||||
"performance": {
|
"performance": {
|
||||||
"noImgElement": "off"
|
"noImgElement": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
|
||||||
"noLabelWithoutControl": "off"
|
|
||||||
},
|
|
||||||
"style": {
|
"style": {
|
||||||
"noDescendingSpecificity": "off"
|
"noDescendingSpecificity": "off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Admin panel", () => {
|
||||||
|
test("admin login page redirects unauthenticated users", async ({ page }) => {
|
||||||
|
await page.goto("/admin");
|
||||||
|
await expect(page).toHaveURL(/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin page has login form", async ({ page }) => {
|
||||||
|
await page.goto("/admin");
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Authentication flows", () => {
|
||||||
|
test("login form validates required fields", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with invalid credentials shows error", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill('input[name="username"]', "nonexistent");
|
||||||
|
await page.fill('input[name="password"]', "wrongpassword");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("register page has password confirmation field", async ({ page }) => {
|
||||||
|
await page.goto("/register");
|
||||||
|
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("register form validates password match", async ({ page }) => {
|
||||||
|
await page.goto("/register");
|
||||||
|
await page.fill('input[name="password"]', "Password123!");
|
||||||
|
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Public navigation", () => {
|
||||||
|
test("homepage loads with expected elements", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("nav")).toBeVisible();
|
||||||
|
await expect(page.locator("footer")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("community page loads", async ({ page }) => {
|
||||||
|
await page.goto("/community");
|
||||||
|
await expect(page).toHaveTitle(/community/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shop page loads", async ({ page }) => {
|
||||||
|
await page.goto("/shop");
|
||||||
|
await expect(page.locator("h1, h2").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("404 page for unknown routes", async ({ page }) => {
|
||||||
|
const response = await page.goto("/this-page-does-not-exist");
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-3
@@ -19,8 +19,8 @@
|
|||||||
"analyze": "ANALYZE=true pnpm build",
|
"analyze": "ANALYZE=true pnpm build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=1000",
|
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=1000",
|
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
||||||
"lhci:collect": "lhci collect",
|
"lhci:collect": "lhci collect",
|
||||||
"lhci:assert": "lhci assert",
|
"lhci:assert": "lhci assert",
|
||||||
"lhci:server": "lhci server",
|
"lhci:server": "lhci server",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"biome check --write",
|
"biome check --write",
|
||||||
"eslint --fix --max-warnings=1000"
|
"eslint --fix --max-warnings=50"
|
||||||
],
|
],
|
||||||
"*.{json,md,css,scss,html}": [
|
"*.{json,md,css,scss,html}": [
|
||||||
"biome format --write"
|
"biome format --write"
|
||||||
@@ -98,6 +98,7 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.7.0",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
|
|||||||
Generated
+110
-11
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -9,7 +9,9 @@ self.addEventListener("activate", (event) => {
|
|||||||
.keys()
|
.keys()
|
||||||
.then((keys) =>
|
.then((keys) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
keys.filter((k) => k !== CACHE && k !== API_CACHE).map((k) => caches.delete(k)),
|
keys
|
||||||
|
.filter((k) => k !== CACHE && k !== API_CACHE)
|
||||||
|
.map((k) => caches.delete(k)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then(() => self.clients.claim()),
|
.then(() => self.clients.claim()),
|
||||||
|
|||||||
+117
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -262,6 +262,7 @@ export function ImportFurniClient() {
|
|||||||
}
|
}
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: toggleSelectAll is a state setter, stable
|
||||||
}, [toggleSelectAll]);
|
}, [toggleSelectAll]);
|
||||||
|
|
||||||
// Derived: unique categories from loaded items
|
// Derived: unique categories from loaded items
|
||||||
|
|||||||
@@ -220,11 +220,13 @@ export function ClientView({
|
|||||||
window.removeEventListener("touchmove", onTouchMove);
|
window.removeEventListener("touchmove", onTouchMove);
|
||||||
window.removeEventListener("touchend", onEnd);
|
window.removeEventListener("touchend", onEnd);
|
||||||
};
|
};
|
||||||
}, [dragging]);
|
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here
|
||||||
|
}, [dragging, snapPos]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pos ? (
|
{pos ? (
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
|
||||||
<div
|
<div
|
||||||
ref={toolbarRef}
|
ref={toolbarRef}
|
||||||
className={`fixed z-[9999] backdrop-blur-xl bg-black/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
className={`fixed z-[9999] backdrop-blur-xl bg-black/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||||
|
|||||||
+1
-1
@@ -1745,7 +1745,7 @@ details[open] > summary .details-open\:rotate-180 {
|
|||||||
/* Input focus glow */
|
/* Input focus glow */
|
||||||
.input-glow:focus {
|
.input-glow:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary) !important;
|
border-color: var(--color-primary);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent),
|
0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent),
|
||||||
0 0 20px color-mix(in srgb, var(--color-primary) 10%, transparent);
|
0 0 20px color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
}
|
}
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
|
||||||
}, [canEdit, isDirty, saving, page, handleSave]);
|
}, [canEdit, isDirty, saving, page, handleSave]);
|
||||||
|
|
||||||
const loadPage = useCallback(
|
const loadPage = useCallback(
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function SortableTree({
|
|||||||
setFilter("all");
|
setFilter("all");
|
||||||
dispatch({ type: "COLLAPSE_ALL" });
|
dispatch({ type: "COLLAPSE_ALL" });
|
||||||
void loadTabTree();
|
void loadTabTree();
|
||||||
}, [activeTabId, loadTabTree]);
|
}, [activeTabId, loadTabTree, dispatch]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -325,7 +325,6 @@ export function SortableTree({
|
|||||||
|
|
||||||
// ── Keyboard navigation ─────────────────────────────────────
|
// ── Keyboard navigation ─────────────────────────────────────
|
||||||
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
|
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ export function CatalogTree({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ export function HomeLoginForm({
|
|||||||
placeholder="Enter your 2FA code"
|
placeholder="Enter your 2FA code"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
autoFocus
|
|
||||||
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--color-background)",
|
backgroundColor: "var(--color-background)",
|
||||||
@@ -195,6 +194,8 @@ export function HomeLoginForm({
|
|||||||
className="animate-spin h-4 w-4"
|
className="animate-spin h-4 w-4"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
role="img"
|
||||||
|
aria-label="Loading"
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
className="opacity-25"
|
className="opacity-25"
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ export function LoginForm({
|
|||||||
placeholder={t("codePlaceholder")}
|
placeholder={t("codePlaceholder")}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
autoFocus
|
|
||||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--color-background)",
|
backgroundColor: "var(--color-background)",
|
||||||
@@ -173,6 +172,8 @@ export function LoginForm({
|
|||||||
className="animate-spin h-4 w-4"
|
className="animate-spin h-4 w-4"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
role="img"
|
||||||
|
aria-label="Loading"
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
className="opacity-25"
|
className="opacity-25"
|
||||||
|
|||||||
@@ -291,62 +291,28 @@ export function RegisterForm({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={termsAccepted}
|
|
||||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
|
||||||
className="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
borderColor: termsAccepted
|
|
||||||
? "var(--color-primary)"
|
|
||||||
: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
|
|
||||||
backgroundColor: termsAccepted
|
|
||||||
? "var(--color-primary)"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{termsAccepted && (
|
|
||||||
<svg
|
|
||||||
className="w-3 h-3"
|
|
||||||
style={{ color: "var(--color-primary-foreground)" }}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
role="img"
|
|
||||||
aria-label="Terms accepted"
|
|
||||||
>
|
|
||||||
<title>Terms accepted</title>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={3}
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="checkbox"
|
||||||
|
id="terms"
|
||||||
name="terms"
|
name="terms"
|
||||||
value={termsAccepted ? "1" : ""}
|
checked={termsAccepted}
|
||||||
|
onChange={(e) => setTermsAccepted(e.target.checked)}
|
||||||
|
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span
|
<label
|
||||||
role="checkbox"
|
htmlFor="terms"
|
||||||
aria-checked={termsAccepted}
|
className="font-semibold cursor-pointer select-none"
|
||||||
tabIndex={0}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setTermsAccepted(!termsAccepted);
|
setTermsAccepted(!termsAccepted);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="font-semibold cursor-pointer select-none"
|
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
|
||||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
|
||||||
>
|
>
|
||||||
{t("termsAccept", { hotel: hotelName })}
|
{t("termsAccept", { hotel: hotelName })}
|
||||||
</span>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -372,11 +338,13 @@ export function RegisterForm({
|
|||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<span className="inline-flex items-center justify-center gap-2">
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
<svg
|
<svg
|
||||||
className="animate-spin h-4 w-4"
|
className="animate-spin h-4 w-4"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
>
|
role="img"
|
||||||
|
aria-label="Loading"
|
||||||
|
>
|
||||||
<circle
|
<circle
|
||||||
className="opacity-25"
|
className="opacity-25"
|
||||||
cx="12"
|
cx="12"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function PwaRegister() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
navigator.serviceWorker.register("/sw.js?" + SW_VERSION).catch(() => {});
|
navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -220,6 +220,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
style={{ color: textColor }}
|
style={{ color: textColor }}
|
||||||
|
role="img"
|
||||||
|
aria-label="Notifications"
|
||||||
>
|
>
|
||||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import type * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
function Label({ className, htmlFor, ...props }: React.ComponentProps<"label">) {
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/a11y/noLabelWithoutControl: reusable component, input is associated via htmlFor at usage site
|
||||||
<label
|
<label
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
|
htmlFor={htmlFor}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
|
|||||||
+4
-1
@@ -60,7 +60,10 @@ export const { handlers, signOut, auth } = NextAuth({
|
|||||||
pages: { signIn: "/login", error: "/login" },
|
pages: { signIn: "/login", error: "/login" },
|
||||||
logger: {
|
logger: {
|
||||||
error(error) {
|
error(error) {
|
||||||
logger.error("NextAuth error", { error: error.message, stack: error.stack });
|
logger.error("NextAuth error", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -19,5 +19,24 @@ export default defineConfig({
|
|||||||
// unit tests run without a populated .env.
|
// unit tests run without a populated .env.
|
||||||
env: { SKIP_ENV_VALIDATION: "1" },
|
env: { SKIP_ENV_VALIDATION: "1" },
|
||||||
exclude: ["e2e/**", "node_modules/**", ".next/**"],
|
exclude: ["e2e/**", "node_modules/**", ".next/**"],
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "lcov", "html"],
|
||||||
|
thresholds: {
|
||||||
|
statements: 5,
|
||||||
|
branches: 3,
|
||||||
|
functions: 4,
|
||||||
|
lines: 5,
|
||||||
|
},
|
||||||
|
include: ["src/**/*.{ts,tsx}"],
|
||||||
|
exclude: [
|
||||||
|
"src/generated/**",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/types/**",
|
||||||
|
"src/test/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user