>;
+ }
+ }
+}
+
+export {};
diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html
new file mode 100644
index 0000000..2cbfac6
--- /dev/null
+++ b/cypress/support/component-index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Components App
+
+
+
+
+
+
+
diff --git a/cypress/support/component.ts b/cypress/support/component.ts
new file mode 100644
index 0000000..b1f1c92
--- /dev/null
+++ b/cypress/support/component.ts
@@ -0,0 +1,38 @@
+// ***********************************************************
+// This example support/component.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+import '@/app/globals.css';
+
+// Import commands.js using ES2015 syntax:
+import './commands';
+
+import { mount } from 'cypress/react';
+
+// Augment the Cypress namespace to include type definitions for
+// your custom command.
+// Alternatively, can be defined in cypress/support/component.d.ts
+// with a at the top of your spec.
+declare global {
+ namespace Cypress {
+ interface Chainable {
+ mount: typeof mount;
+ }
+ }
+}
+
+Cypress.Commands.add('mount', mount);
+
+// Example use:
+// cy.mount()
diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts
new file mode 100644
index 0000000..e66558e
--- /dev/null
+++ b/cypress/support/e2e.ts
@@ -0,0 +1,17 @@
+// ***********************************************************
+// This example support/e2e.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands';
diff --git a/package.json b/package.json
index a2f1584..2cdd489 100644
--- a/package.json
+++ b/package.json
@@ -5,9 +5,13 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "prettier --check . && next build",
- "start": "next start",
+ "start": "node .next/standalone/server.js",
"lint": "next lint",
- "format": "prettier --write ."
+ "format": "prettier --write .",
+ "cypress:build": "prettier --check . && NODE_ENV=test next build",
+ "cypress:start_server": "cp .env.test .next/standalone && cp public .next/standalone/ -r && cp .next/static/ .next/standalone/.next/ -r && NODE_ENV=test HOSTNAME=\"0.0.0.0\" dotenv -e .env.test -- node .next/standalone/server.js",
+ "cypress:open": "cypress open",
+ "cypress:run": "cypress run"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
@@ -38,8 +42,10 @@
"@eslint/eslintrc": "3.3.1",
"@tailwindcss/postcss": "4.1.6",
"@types/node": "22.15.17",
- "@types/react": "19.1.3",
- "@types/react-dom": "19.1.4",
+ "@types/react": "19.1.4",
+ "@types/react-dom": "19.1.5",
+ "cypress": "14.3.3",
+ "dotenv-cli": "^8.0.0",
"eslint": "9.26.0",
"eslint-config-next": "15.3.2",
"eslint-config-prettier": "10.1.5",
diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx
index c61abdd..4e6773b 100644
--- a/src/app/home/page.tsx
+++ b/src/app/home/page.tsx
@@ -1,4 +1,3 @@
-import { Logout } from '@/components/user/sso-logout-button';
import { RedirectButton } from '@/components/user/redirect-button';
import { ThemePicker } from '@/components/user/theme-picker';
@@ -8,7 +7,7 @@ export default function Home() {
{}
Home
-
+
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 714e996..34d1b07 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -1,4 +1,4 @@
-import { auth } from '@/auth';
+import { auth, providerMap } from '@/auth';
import SSOLogin from '@/components/user/sso-login-button';
import LoginForm from '@/components/user/login-form';
import { redirect } from 'next/navigation';
@@ -30,16 +30,23 @@ export default async function LoginPage() {
- Login
+
+ Login
+
-
+ {providerMap.length > 0 &&
}
- {process.env.AUTH_AUTHENTIK_ISSUER && (
-
- )}
+ {providerMap.map((provider) => (
+
+ ))}
diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx
new file mode 100644
index 0000000..15f29aa
--- /dev/null
+++ b/src/app/logout/page.tsx
@@ -0,0 +1,40 @@
+import { signOut } from '@/auth';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+
+export default function SignOutPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 7bcd29e..a86e576 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,3 +1,9 @@
-export default function Home() {
- return ;
+import { auth } from '@/auth';
+import { redirect } from 'next/navigation';
+
+export default async function Home() {
+ const session = await auth();
+
+ if (!session?.user) redirect('/login');
+ else redirect('/home');
}
diff --git a/src/auth.ts b/src/auth.ts
index 50b654c..09a5065 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -1,13 +1,49 @@
import NextAuth from 'next-auth';
+
+import type { Provider } from 'next-auth/providers';
+import Credentials from 'next-auth/providers/credentials';
+
import Authentik from 'next-auth/providers/authentik';
+const providers: Provider[] = [
+ !process.env.DISABLE_PASSWORD_LOGIN &&
+ Credentials({
+ credentials: { password: { label: 'Password', type: 'password' } },
+ authorize(c) {
+ if (c.password !== 'password') return null;
+ return {
+ id: 'test',
+ name: 'Test User',
+ email: 'test@example.com',
+ };
+ },
+ }),
+ process.env.AUTH_AUTHENTIK_ID && Authentik,
+].filter(Boolean) as Provider[];
+
+export const providerMap = providers
+ .map((provider) => {
+ if (typeof provider === 'function') {
+ const providerData = provider();
+ return { id: providerData.id, name: providerData.name };
+ } else {
+ return { id: provider.id, name: provider.name };
+ }
+ })
+ .filter((provider) => provider.id !== 'credentials');
+
export const { handlers, signIn, signOut, auth } = NextAuth({
- providers: [process.env.AUTH_AUTHENTIK_ISSUER ? Authentik : null].filter(
- (x) => x !== null,
- ),
+ providers,
+ session: {
+ strategy: 'jwt',
+ },
+ pages: {
+ signIn: '/login',
+ signOut: '/logout',
+ },
callbacks: {
- authorized: async ({ auth }) => {
- return !!auth;
+ authorized({ auth }) {
+ return !!auth?.user;
},
},
});
diff --git a/src/components/icon-button.cy.tsx b/src/components/icon-button.cy.tsx
new file mode 100644
index 0000000..b5a1ff0
--- /dev/null
+++ b/src/components/icon-button.cy.tsx
@@ -0,0 +1,24 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions */
+import React from 'react';
+import { IconButton } from './icon-button';
+import { faOpenid } from '@fortawesome/free-brands-svg-icons';
+
+describe('', () => {
+ it('renders', () => {
+ cy.mount(Button);
+ });
+
+ it('is clickable', () => {
+ const onClick = cy.stub();
+ cy.mount(
+
+ Button
+ ,
+ );
+ cy.getBySel('icon-button')
+ .click()
+ .then(() => {
+ expect(onClick).to.be.calledOnce;
+ });
+ });
+});
diff --git a/src/components/labeled-input.tsx b/src/components/labeled-input.tsx
index 7b4768a..840d599 100644
--- a/src/components/labeled-input.tsx
+++ b/src/components/labeled-input.tsx
@@ -6,23 +6,25 @@ export default function LabeledInput({
label,
placeholder,
value,
+ ...props
}: {
type: 'text' | 'email' | 'password';
label: string;
placeholder?: string;
value?: string;
-}) {
+} & React.InputHTMLAttributes) {
const elementId = Math.random().toString(36).substring(2, 15);
return (
-
+
);
diff --git a/src/components/user/login-form.tsx b/src/components/user/login-form.tsx
index 20438e8..0e332bd 100644
--- a/src/components/user/login-form.tsx
+++ b/src/components/user/login-form.tsx
@@ -1,23 +1,47 @@
+import { signIn } from '@/auth';
import LabeledInput from '@/components/labeled-input';
import { Button } from '@/components/ui/button';
+import { AuthError } from 'next-auth';
+import { redirect } from 'next/navigation';
+
+const SIGNIN_ERROR_URL = '/error';
export default function LoginForm() {
return (
-