Compare commits

...

1 commit

Author SHA1 Message Date
Lea f00cf653c0
stuff 2024-06-16 23:35:53 +02:00
9 changed files with 258 additions and 225 deletions

View file

@ -16,7 +16,7 @@
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"lucide-react": "^0.311.0", "lucide-react": "^0.311.0",
"next": "14.0.4", "next": "14.0.4",
"next-auth": "^4.24.5", "next-auth": "5.0.0-beta.18",
"next-swagger-doc": "^0.4.0", "next-swagger-doc": "^0.4.0",
"random-words": "^2.0.0", "random-words": "^2.0.0",
"react": "^18", "react": "^18",

View file

@ -27,8 +27,8 @@ dependencies:
specifier: 14.0.4 specifier: 14.0.4
version: 14.0.4(react-dom@18.2.0)(react@18.2.0) version: 14.0.4(react-dom@18.2.0)(react@18.2.0)
next-auth: next-auth:
specifier: ^4.24.5 specifier: 5.0.0-beta.18
version: 4.24.5(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) version: 5.0.0-beta.18(next@14.0.4)(react@18.2.0)
next-swagger-doc: next-swagger-doc:
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.0(next@14.0.4)(openapi-types@12.1.3) version: 0.4.0(next@14.0.4)(openapi-types@12.1.3)
@ -124,6 +124,29 @@ packages:
z-schema: 5.0.5 z-schema: 5.0.5
dev: false dev: false
/@auth/core@0.31.0:
resolution: {integrity: sha512-UKk3psvA1cRbk4/c9CkpWB8mdWrkKvzw0DmEYRsWolUQytQ2cRqx+hYuV6ZCsngw/xbj9hpmkZmAZEyq2g4fMg==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
dependencies:
'@panva/hkdf': 1.1.1
'@types/cookie': 0.6.0
cookie: 0.6.0
jose: 5.3.0
oauth4webapi: 2.10.4
preact: 10.11.3
preact-render-to-string: 5.2.3(preact@10.11.3)
dev: false
/@babel/runtime-corejs3@7.23.8: /@babel/runtime-corejs3@7.23.8:
resolution: {integrity: sha512-2ZzmcDugdm0/YQKFVYsXiwUN7USPX8PM7cytpb4PFl87fM+qYPSvTZX//8tyeJB1j0YDmafBJEbl5f8NfLyuKw==} resolution: {integrity: sha512-2ZzmcDugdm0/YQKFVYsXiwUN7USPX8PM7cytpb4PFl87fM+qYPSvTZX//8tyeJB1j0YDmafBJEbl5f8NfLyuKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -1976,6 +1999,10 @@ packages:
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
dev: false dev: false
/@types/cookie@0.6.0:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
dev: false
/@types/hast@2.3.9: /@types/hast@2.3.9:
resolution: {integrity: sha512-pTHyNlaMD/oKJmS+ZZUyFUcsZeBZpC0lmGquw98CqRVNgAdJZJeD7GoeLiT6Xbx5rU9VCjSt0RwEvDgzh4obFw==} resolution: {integrity: sha512-pTHyNlaMD/oKJmS+ZZUyFUcsZeBZpC0lmGquw98CqRVNgAdJZJeD7GoeLiT6Xbx5rU9VCjSt0RwEvDgzh4obFw==}
dependencies: dependencies:
@ -2634,11 +2661,6 @@ packages:
dev: false dev: false
optional: true optional: true
/cookie@0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
dev: false
/cookie@0.6.0: /cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -3973,8 +3995,8 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/jose@4.15.4: /jose@5.3.0:
resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} resolution: {integrity: sha512-IChe9AtAE79ru084ow8jzkN2lNrG3Ntfiv65Cvj9uOCE2m5LNsdHG+9EbxWxAoWRF9TgDOqLN5jm08++owDVRg==}
dev: false dev: false
/js-file-download@0.4.12: /js-file-download@0.4.12:
@ -4358,29 +4380,25 @@ packages:
dev: false dev: false
optional: true optional: true
/next-auth@4.24.5(next@14.0.4)(react-dom@18.2.0)(react@18.2.0): /next-auth@5.0.0-beta.18(next@14.0.4)(react@18.2.0):
resolution: {integrity: sha512-3RafV3XbfIKk6rF6GlLE4/KxjTcuMCifqrmD+98ejFq73SRoj2rmzoca8u764977lH/Q7jo6Xu6yM+Re1Mz/Og==} resolution: {integrity: sha512-x55L8wZb8PcPGCYA3e/l9tdpd7YL3FDuhas4W8pxq3PjrWJ9OoDxNN0otK9axJamJBbBgjfzTJjVQB6hXoe0ZQ==}
peerDependencies: peerDependencies:
next: ^12.2.5 || ^13 || ^14 '@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14
nodemailer: ^6.6.5 nodemailer: ^6.6.5
react: ^17.0.2 || ^18 react: ^18.2.0
react-dom: ^17.0.2 || ^18
peerDependenciesMeta: peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer: nodemailer:
optional: true optional: true
dependencies: dependencies:
'@babel/runtime': 7.23.8 '@auth/core': 0.31.0
'@panva/hkdf': 1.1.1
cookie: 0.5.0
jose: 4.15.4
next: 14.0.4(react-dom@18.2.0)(react@18.2.0) next: 14.0.4(react-dom@18.2.0)(react@18.2.0)
oauth: 0.9.15
openid-client: 5.6.4
preact: 10.19.3
preact-render-to-string: 5.2.6(preact@10.19.3)
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
uuid: 8.3.2
dev: false dev: false
/next-swagger-doc@0.4.0(next@14.0.4)(openapi-types@12.1.3): /next-swagger-doc@0.4.0(next@14.0.4)(openapi-types@12.1.3):
@ -4525,19 +4543,14 @@ packages:
dev: false dev: false
optional: true optional: true
/oauth@0.9.15: /oauth4webapi@2.10.4:
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} resolution: {integrity: sha512-DSoj8QoChzOCQlJkRmYxAJCIpnXFW32R0Uq7avyghIeB6iJq0XAblOD7pcq3mx4WEBDwMuKr0Y1qveCBleG2Xw==}
dev: false dev: false
/object-assign@4.1.1: /object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
/object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
dev: false
/object-hash@3.0.0: /object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -4603,11 +4616,6 @@ packages:
es-abstract: 1.22.3 es-abstract: 1.22.3
dev: true dev: true
/oidc-token-hash@5.0.3:
resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==}
engines: {node: ^10.13.0 || >=12.0.0}
dev: false
/once@1.4.0: /once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies: dependencies:
@ -4625,15 +4633,6 @@ packages:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
dev: false dev: false
/openid-client@5.6.4:
resolution: {integrity: sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==}
dependencies:
jose: 4.15.4
lru-cache: 6.0.0
object-hash: 2.2.0
oidc-token-hash: 5.0.3
dev: false
/optionator@0.9.3: /optionator@0.9.3:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -4840,17 +4839,17 @@ packages:
source-map-js: 1.0.2 source-map-js: 1.0.2
dev: true dev: true
/preact-render-to-string@5.2.6(preact@10.19.3): /preact-render-to-string@5.2.3(preact@10.11.3):
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
peerDependencies: peerDependencies:
preact: '>=10' preact: '>=10'
dependencies: dependencies:
preact: 10.19.3 preact: 10.11.3
pretty-format: 3.8.0 pretty-format: 3.8.0
dev: false dev: false
/preact@10.19.3: /preact@10.11.3:
resolution: {integrity: sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==} resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
dev: false dev: false
/prebuild-install@7.1.1: /prebuild-install@7.1.1:
@ -6117,11 +6116,6 @@ packages:
/util-deprecate@1.0.2: /util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
/uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
dev: false
/validator@13.11.0: /validator@13.11.0:
resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==} resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}

View file

@ -2,6 +2,11 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
a {
text-decoration: none;
color: var(--accent-a11);
}
/* mfw "ui library" doesnt ship styles */ /* mfw "ui library" doesnt ship styles */
.ToastViewport { .ToastViewport {
--viewport-padding: 25px; --viewport-padding: 25px;

View file

@ -2,7 +2,7 @@ import type { Metadata } from 'next';
import ThemeWrapper from '@/lib/components/wrapper/ThemeWrapper'; import ThemeWrapper from '@/lib/components/wrapper/ThemeWrapper';
import './globals.css'; import './globals.css';
import AuthWrapper from '@/lib/components/wrapper/AuthWrapper'; import AuthWrapper from '@/lib/components/wrapper/AuthWrapper';
import { getServerSession } from 'next-auth'; import { auth } from "@/auth";
import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper'; import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper';
import BackgroundImage from '@/lib/components/ui/BackgroundImage'; import BackgroundImage from '@/lib/components/ui/BackgroundImage';
import ToastProvider from '@/lib/providers/ToastProvider'; import ToastProvider from '@/lib/providers/ToastProvider';
@ -23,7 +23,7 @@ export default async function RootLayout({
<ThemeWrapper> <ThemeWrapper>
<BackgroundImage /> <BackgroundImage />
<ToastProvider> <ToastProvider>
<AuthWrapper session={await getServerSession()}> <AuthWrapper session={await auth()}>
<NavigationWrapper> <NavigationWrapper>
{children} {children}
</NavigationWrapper> </NavigationWrapper>

View file

@ -1,50 +1,2 @@
import NextAuth, { AuthOptions } from "next-auth"; import { handlers } from "@/auth";
import CredentialProvider from "next-auth/providers/credentials"; export const { GET, POST } = handlers;
import { sha256sum } from "@/lib/util";
import { validateCredentials } from "@/lib/db";
import { GRAVATAR_DEFAULT } from "@/lib/constants";
import { auditLogRaw } from "@/lib/audit";
const authOptions: AuthOptions = {
providers: [
CredentialProvider({
name: 'Mail account',
credentials: {
email: { label: "E-Mail", type: "email", placeholder: "webmistress@amogus.cloud" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
console.log(`[${credentials?.email}] Authentication attempt`);
if (credentials && await validateCredentials(credentials.email, credentials.password)) {
console.log(`[${credentials.email}] Authentication succeeded`);
const emailHash = sha256sum(credentials.email.trim().toLowerCase());
await auditLogRaw({
user: credentials.email,
ts: new Date().toISOString(),
action: "login",
});
return {
id: credentials.email,
email: credentials.email,
image: `https://gravatar.com/avatar/${emailHash}?d=${GRAVATAR_DEFAULT}`,
};
}
console.log(`[${credentials?.email}] Authentication failed`);
return null;
},
}),
],
session: {
strategy: 'jwt',
maxAge: 4 * 60 * 60 // 4 hours
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

58
src/auth.ts Normal file
View file

@ -0,0 +1,58 @@
import nextAuth, { CredentialsSignin } from "next-auth";
import CredentialProvider from "next-auth/providers/credentials";
import WebAuthnProvider from "next-auth/providers/webauthn";
import { sha256sum } from "@/lib/util";
import { validateCredentials } from "@/lib/db";
import { GRAVATAR_DEFAULT } from "@/lib/constants";
import { auditLogRaw } from "@/lib/audit";
export const { auth, handlers, signIn, signOut } = nextAuth({
providers: [
CredentialProvider({
name: "Mail Account",
credentials: {
email: { label: "E-Mail", type: "email", placeholder: "webmistress@amogus.cloud" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
if (typeof credentials.email != "string" || typeof credentials.password != "string") {
console.log("Someone tried to fuck with the auth payload, uh oh");
throw new CredentialsSignin("what");
}
console.log(`[${credentials?.email}] Authentication attempt`);
if (credentials && await validateCredentials(credentials.email, credentials.password)) {
console.log(`[${credentials.email}] Authentication succeeded`);
const emailHash = sha256sum(credentials.email.trim().toLowerCase());
await auditLogRaw({
user: credentials.email,
ts: new Date().toISOString(),
action: "login",
});
return {
id: credentials.email,
email: credentials.email,
image: `https://gravatar.com/avatar/${emailHash}?d=${GRAVATAR_DEFAULT}`,
};
}
console.log(`[${credentials?.email}] Authentication failed`);
return null;
},
}),
WebAuthnProvider({
name: "Passkey",
}),
],
session: {
strategy: 'jwt',
maxAge: 4 * 60 * 60, // 4 hours
},
experimental: {
enableWebAuthn: true,
}
});

View file

@ -2,7 +2,7 @@
import crypto from "crypto"; import crypto from "crypto";
import fs from "fs/promises"; import fs from "fs/promises";
import { getServerSession } from "next-auth"; import { auth } from "@/auth";
import { AuditLog, auditLog } from "./audit"; import { AuditLog, auditLog } from "./audit";
import { AliasEntry, AliasRequestEntry, ApiKeyEntry, approveAliasEntry, createAliasEntry, createApiKeyEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteApiKey, deleteTempAliasRequestEntry, getAlias, getAllAliases, getApiKeyById, getTempAliasRequestEntry, getUserAliases, getUserApiKeys, isAliasAvailable, setUserPassword } from "./db"; import { AliasEntry, AliasRequestEntry, ApiKeyEntry, approveAliasEntry, createAliasEntry, createApiKeyEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteApiKey, deleteTempAliasRequestEntry, getAlias, getAllAliases, getApiKeyById, getTempAliasRequestEntry, getUserAliases, getUserApiKeys, isAliasAvailable, setUserPassword } from "./db";
import { aliasesNeedApproval, anonymizeApiKey, isAdmin } from "./util"; import { aliasesNeedApproval, anonymizeApiKey, isAdmin } from "./util";
@ -10,7 +10,7 @@ import { generateAliasEmail } from "./util-server";
export async function fetchAllUsers(): Promise<string[]> { export async function fetchAllUsers(): Promise<string[]> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const session = await getServerSession(); const session = await auth();
if (!isAdmin(session)) return reject("Unauthenticated"); if (!isAdmin(session)) return reject("Unauthenticated");
const db = database('credentials'); const db = database('credentials');
@ -24,20 +24,20 @@ export async function fetchAllUsers(): Promise<string[]> {
export async function changeOwnPassword(newPass: string) { export async function changeOwnPassword(newPass: string) {
if (newPass.length < 8) throw new Error("Invalid password"); if (newPass.length < 8) throw new Error("Invalid password");
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
await setUserPassword(session.user.email, newPass); await setUserPassword(session.user.email, newPass);
auditLog("changeOwnPassword"); auditLog("changeOwnPassword");
} }
export async function fetchOwnAliases(tempAliases?: boolean) { export async function fetchOwnAliases(tempAliases?: boolean) {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
return await getUserAliases(session.user.email, tempAliases); return await getUserAliases(session.user.email, tempAliases);
} }
export async function fetchUserAliases(email: string) { export async function fetchUserAliases(email: string) {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session)) throw new Error("Unauthorized"); if (!isAdmin(session)) throw new Error("Unauthorized");
@ -45,7 +45,7 @@ export async function fetchUserAliases(email: string) {
} }
export async function fetchAllAliases() { export async function fetchAllAliases() {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session)) throw new Error("Unauthorized"); if (!isAdmin(session)) throw new Error("Unauthorized");
@ -53,14 +53,14 @@ export async function fetchAllAliases() {
} }
export async function aliasAvailable(email: string, searchTempRequests: boolean = false) { export async function aliasAvailable(email: string, searchTempRequests: boolean = false) {
const session = await getServerSession(); const session = await auth();
if (!session?.user) throw new Error("Unauthenticated"); if (!session?.user) throw new Error("Unauthenticated");
return await isAliasAvailable(email, searchTempRequests); return await isAliasAvailable(email, searchTempRequests);
} }
export async function createAlias(user: string, alias: string): Promise<AliasEntry> { export async function createAlias(user: string, alias: string): Promise<AliasEntry> {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session)) throw new Error("Unauthenticated"); if (!isAdmin(session)) throw new Error("Unauthenticated");
if (!await isAliasAvailable(alias)) throw new Error("Alias unavailable"); if (!await isAliasAvailable(alias)) throw new Error("Alias unavailable");
@ -80,7 +80,7 @@ export async function createAlias(user: string, alias: string): Promise<AliasEnt
} }
export async function createAliasSelf(alias: string): Promise<AliasEntry> { export async function createAliasSelf(alias: string): Promise<AliasEntry> {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
const pending = aliasesNeedApproval(session); const pending = aliasesNeedApproval(session);
@ -105,7 +105,7 @@ export async function requestTemporaryAlias(
style: 'words' | 'random', style: 'words' | 'random',
oldToken?: string, oldToken?: string,
): Promise<AliasRequestEntry> { ): Promise<AliasRequestEntry> {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!label.length || label.length > 16) throw new Error("Malformed request"); if (!label.length || label.length > 16) throw new Error("Malformed request");
@ -134,7 +134,7 @@ export async function requestTemporaryAlias(
} }
export async function claimTemporaryAlias(key: string): Promise<AliasEntry> { export async function claimTemporaryAlias(key: string): Promise<AliasEntry> {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
const data = await getTempAliasRequestEntry(key); const data = await getTempAliasRequestEntry(key);
@ -159,7 +159,7 @@ export async function claimTemporaryAlias(key: string): Promise<AliasEntry> {
} }
export async function disposeTempAliasRequest(key: string) { export async function disposeTempAliasRequest(key: string) {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
const data = await getTempAliasRequestEntry(key); const data = await getTempAliasRequestEntry(key);
@ -170,7 +170,7 @@ export async function disposeTempAliasRequest(key: string) {
} }
export async function deleteAlias(alias: string) { export async function deleteAlias(alias: string) {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session) && (await getAlias(alias))?.address != session.user.email) { if (!isAdmin(session) && (await getAlias(alias))?.address != session.user.email) {
@ -182,7 +182,7 @@ export async function deleteAlias(alias: string) {
} }
export async function approveAlias(alias: string) { export async function approveAlias(alias: string) {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session)) throw new Error("Unauthorized"); if (!isAdmin(session)) throw new Error("Unauthorized");
@ -191,7 +191,7 @@ export async function approveAlias(alias: string) {
} }
export async function createUser(email: string, password: string) { export async function createUser(email: string, password: string) {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session)) throw new Error("Unauthorized"); if (!isAdmin(session)) throw new Error("Unauthorized");
@ -210,7 +210,7 @@ export async function createUser(email: string, password: string) {
export async function fetchAuditLog(page: number): Promise<{ page: number, perPage: number, totalItems: number, items: (AuditLog & { index: number })[] }> { export async function fetchAuditLog(page: number): Promise<{ page: number, perPage: number, totalItems: number, items: (AuditLog & { index: number })[] }> {
const itemsPerPage = 10; const itemsPerPage = 10;
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session)) throw new Error("Unauthorized"); if (!isAdmin(session)) throw new Error("Unauthorized");
@ -239,7 +239,7 @@ export async function fetchAuditLog(page: number): Promise<{ page: number, perPa
} }
export async function createApiKey(label: string): Promise<ApiKeyEntry> { export async function createApiKey(label: string): Promise<ApiKeyEntry> {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
if (!label.length || label.length > 64) throw new Error("Malformed label"); if (!label.length || label.length > 64) throw new Error("Malformed label");
@ -260,7 +260,7 @@ export async function createApiKey(label: string): Promise<ApiKeyEntry> {
} }
export async function fetchOwnApiKeys(): Promise<ApiKeyEntry[]> { export async function fetchOwnApiKeys(): Promise<ApiKeyEntry[]> {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
const keys = await getUserApiKeys(session.user.email); const keys = await getUserApiKeys(session.user.email);
@ -268,7 +268,7 @@ export async function fetchOwnApiKeys(): Promise<ApiKeyEntry[]> {
} }
export async function deleteOwnApikey(id: number) { export async function deleteOwnApikey(id: number) {
const session = await getServerSession(); const session = await auth();
if (!session?.user?.email) throw new Error("Unauthenticated"); if (!session?.user?.email) throw new Error("Unauthenticated");
const key = await getApiKeyById(id); const key = await getApiKeyById(id);

View file

@ -1,4 +1,4 @@
import { getServerSession } from "next-auth"; import { auth } from "@/auth";
import fs from "fs/promises"; import fs from "fs/promises";
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' | 'createTempAlias' // Unprivileged export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' | 'createTempAlias' // Unprivileged
@ -15,7 +15,7 @@ export type AuditLog = {
export function auditLog(action: AuditLogAction, data?: any) { export function auditLog(action: AuditLogAction, data?: any) {
(async () => { (async () => {
try { try {
const session = await getServerSession(); const session = await auth();
const log: AuditLog = { const log: AuditLog = {
user: session?.user?.email, user: session?.user?.email,

View file

@ -2,9 +2,10 @@
import { changeOwnPassword } from "@/lib/actions"; import { changeOwnPassword } from "@/lib/actions";
import { ToastContext } from "@/lib/providers/ToastProvider"; import { ToastContext } from "@/lib/providers/ToastProvider";
import { Box, Button, Callout, Card, Dialog, Flex, Heading, IconButton, Link, Popover, Text, TextField, Tooltip } from "@radix-ui/themes"; import { Box, Button, Callout, Card, Dialog, Flex, Heading, IconButton, Popover, Tabs, Text, TextField, Tooltip } from "@radix-ui/themes";
import { AlertCircleIcon, InfoIcon, LockIcon, UserIcon } from "lucide-react"; import { AlertCircleIcon, InfoIcon, LockIcon, UserIcon } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Link from "next/link";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
export default function OwnCredentialsCard() { export default function OwnCredentialsCard() {
@ -16,110 +17,133 @@ export default function OwnCredentialsCard() {
return ( return (
<Card className="h-fit"> <Card className="h-fit">
<Heading size="3">Credentials</Heading> <Heading size="3">Credentials</Heading>
<Text weight="light" size="2">These are the details you can use to authenticate via SMTP and IMAP.</Text> <Text weight="light" size="2">These are the details you can use to authenticate to the webmail, settings panel and via other mail clients.</Text>
<Flex direction="column" gap="3" className="pt-2"> <Tabs.Root defaultValue="password">
<TextField.Root> <Tabs.List size="2">
<TextField.Slot> <Tabs.Trigger value="password">Password</Tabs.Trigger>
<UserIcon height="16" width="16" /> <Tabs.Trigger value="passkey">Passkeys</Tabs.Trigger>
</TextField.Slot> </Tabs.List>
<TextField.Input disabled value={session?.user?.email ?? 'n/a'} />
<TextField.Slot>
<Popover.Root>
<Popover.Trigger>
<IconButton asChild size="1" variant="ghost">
<InfoIcon height="16" width="16" />
</IconButton>
</Popover.Trigger>
<Popover.Content className="w-96">
<Text>
Your username uniquely identifies your account and therefore cannot be changed. If you need a different email address, you can request an alias instead.
</Text>
</Popover.Content>
</Popover.Root>
</TextField.Slot>
</TextField.Root>
<Flex direction="row" gap="3">
<TextField.Root className="flex-1">
<TextField.Slot>
<LockIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input disabled value={"\u25CF".repeat(10)} />
</TextField.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline">
Change
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Update password</Dialog.Title>
<Box pb="4">
<Callout.Root>
<Callout.Icon>
<AlertCircleIcon />
</Callout.Icon>
<Callout.Text>You will be logged out of every configured mail client!</Callout.Text>
</Callout.Root>
</Box>
<Box pt="3" pb="2">
<Tabs.Content value="password">
<Flex direction="column" gap="3" className="pt-2">
<TextField.Root> <TextField.Root>
<TextField.Slot> <TextField.Slot>
<LockIcon height="16" width="16" /> <UserIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input disabled value={session?.user?.email ?? 'n/a'} />
<TextField.Slot>
<Popover.Root>
<Popover.Trigger>
<IconButton asChild size="1" variant="ghost">
<InfoIcon height="16" width="16" />
</IconButton>
</Popover.Trigger>
<Popover.Content className="w-96">
<Text>
Your username uniquely identifies your account and therefore cannot be changed. If you need a different email address, you can request an alias instead.
</Text>
</Popover.Content>
</Popover.Root>
</TextField.Slot> </TextField.Slot>
<TextField.Input
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
/>
</TextField.Root> </TextField.Root>
<Flex gap="3" mt="4" justify="end"> <Flex direction="row" gap="3">
<Dialog.Close> <TextField.Root className="flex-1">
<Button variant="outline" onClick={() => setNewPassword("")}>Cancel</Button> <TextField.Slot>
</Dialog.Close> <LockIcon height="16" width="16" />
<Dialog.Close> </TextField.Slot>
{validPassword <TextField.Input disabled value={"\u25CF".repeat(10)} />
? ( </TextField.Root>
<Button
variant="soft"
onClick={async () => {
setNewPassword("");
try { <Dialog.Root>
await changeOwnPassword(newPassword); <Dialog.Trigger>
toast({ <Button variant="outline">
title: "Password changed", Change
variant: 'success', </Button>
}); </Dialog.Trigger>
} catch (e) {
console.error(e); <Dialog.Content>
toast({ <Dialog.Title>Update password</Dialog.Title>
title: "Password change failed",
description: `${e}`, <Box pb="4">
variant: 'error', <Callout.Root>
}); <Callout.Icon>
} <AlertCircleIcon />
}} </Callout.Icon>
>Update</Button> <Callout.Text>You will be logged out of every configured mail client!</Callout.Text>
) </Callout.Root>
: ( </Box>
<Tooltip content="The password must have a minimum length of 8 characters">
<Button variant="soft" disabled>Update</Button> <TextField.Root>
</Tooltip> <TextField.Slot>
) <LockIcon height="16" width="16" />
} </TextField.Slot>
</Dialog.Close> <TextField.Input
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
/>
</TextField.Root>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline" onClick={() => setNewPassword("")}>Cancel</Button>
</Dialog.Close>
<Dialog.Close>
{validPassword
? (
<Button
variant="soft"
onClick={async () => {
setNewPassword("");
try {
await changeOwnPassword(newPassword);
toast({
title: "Password changed",
variant: 'success',
});
} catch (e) {
console.error(e);
toast({
title: "Password change failed",
description: `${e}`,
variant: 'error',
});
}
}}
>Update</Button>
)
: (
<Tooltip content="The password must have a minimum length of 8 characters">
<Button variant="soft" disabled>Update</Button>
</Tooltip>
)
}
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex> </Flex>
</Dialog.Content> </Flex>
</Dialog.Root> </Tabs.Content>
</Flex> <Tabs.Content value="passkey">
</Flex> <Card>
<Text weight="light" size="2">
You can use a passkey (<Link href="https://bitwarden.com/passwordless-passkeys/">software</Link> or
<Link href="https://www.yubico.com/products/yubikey-5-overview/">hardware</Link> based) instead of
your password to sign into the web UI. However, they are not compatible with the webmail or external
mail clients.
</Text>
</Card>
</Tabs.Content>
</Box>
</Tabs.Root>
</Card> </Card>
); );
} }