diff --git a/package-lock.json b/package-lock.json
index ef62603..f3f18db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"jose": "^5.3.0",
"mime-types": "^2.1.35",
"mysql2": "^3.9.7",
+ "nodemailer": "^6.9.13",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
@@ -29,6 +30,7 @@
"@types/eslint": "^8.56.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.12.12",
+ "@types/nodemailer": "^6.4.15",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -1559,6 +1561,15 @@
"undici-types": "~5.26.4"
}
},
+ "node_modules/@types/nodemailer": {
+ "version": "6.4.15",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz",
+ "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/pug": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
@@ -4108,6 +4119,14 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"dev": true
},
+ "node_modules/nodemailer": {
+ "version": "6.9.13",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz",
+ "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
diff --git a/package.json b/package.json
index 5587956..8cfd4bc 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@types/eslint": "^8.56.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.12.12",
+ "@types/nodemailer": "^6.4.15",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -44,6 +45,7 @@
"jose": "^5.3.0",
"mime-types": "^2.1.35",
"mysql2": "^3.9.7",
+ "nodemailer": "^6.9.13",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
diff --git a/src/app.css b/src/app.css
index 7398b29..738a483 100644
--- a/src/app.css
+++ b/src/app.css
@@ -11,10 +11,12 @@
--in-normalized-background: #000;
--in-input-background: #fff;
--in-input-background-disabled: #c2c2c2;
+ --in-input-required-color: #ff0000;
--in-input-color: #000;
--in-input-color-disabled: #414141;
--in-input-border-color: #ddd;
--in-input-border-color-disabled: #a0a0a0;
+ --in-input-border-color-invalid: #ff0000;
--in-alert-color: #006597;
--in-error-color: #b52e2e;
diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte
index a2a04bc..fc5fe58 100644
--- a/src/lib/components/Button.svelte
+++ b/src/lib/components/Button.svelte
@@ -3,10 +3,13 @@
export let type: 'button' | 'submit' = 'button';
export let variant: 'default' | 'primary' | 'link' = 'default';
+ export let disabled = false;
const dispath = createEventDispatcher();
- dispath('click', e)}>
+ dispath('click', e)} {disabled}
+ >
diff --git a/src/lib/components/form/FormSection.svelte b/src/lib/components/form/FormSection.svelte
index 3b82774..6bcf124 100644
--- a/src/lib/components/form/FormSection.svelte
+++ b/src/lib/components/form/FormSection.svelte
@@ -1,9 +1,13 @@
@@ -20,4 +24,15 @@
font-size: 1.2rem;
font-weight: 700;
}
+
+ .form-required {
+ font-size: 0.9rem;
+
+ &::before {
+ content: '*';
+ color: var(--in-input-required-color);
+ margin-right: 6px;
+ font-weight: 700;
+ }
+ }
diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json
index 0caace5..ed8c56e 100644
--- a/src/lib/i18n/en/account.json
+++ b/src/lib/i18n/en/account.json
@@ -1,17 +1,23 @@
{
"title": "Manage your account",
"username": "Username",
+ "usernameHint": "Only the English alphabet, numbers and _-. are allowed.",
"displayName": "Display Name",
+ "displayNameHint": "Must be between 3 and 32 characters.",
"changeEmail": "Change email address",
"currentEmail": "Current email address",
+ "email": "Email address",
"newEmail": "New email address",
+ "password": "Password",
+ "repeatPassword": "Repeat password",
"changePassword": "Change password",
"currentPassword": "Current password",
"newPassword": "New password",
- "repeatPassword": "Repeat new password",
+ "repeatNewPassword": "Repeat new password",
"passwordHint": "At least 8 characters, a capital letter and a number required.",
"submit": "Submit",
"changeSuccess": "Account settings changed successfully!",
+ "activateSuccess": "Your account has been activated successfully! You may now log in.",
"avatar": {
"title": "Profile avatar",
"change": "Change avatar",
@@ -30,16 +36,27 @@
"otp": "Two-factor authentication is enabled",
"otpCode": "Enter the code displayed on the authenticator app"
},
+ "register": {
+ "title": "Create a new account",
+ "disabled": "Registrations are currently disabled by the administrator! Sorry.",
+ "userCreated": "User account has been created successfully! You may now log in.",
+ "emailSent": "User account has been created successfully, and an activation email has been sent to your email address.",
+ "submit": "Create account"
+ },
"errors": {
"invalidLogin": "Invalid email or password!",
"invalidRequest": "Invalid request! Please try again.",
"emailRequired": "Email address is required.",
+ "invalidUsername": "The username entered is invalid or not allowed",
"invalidEmail": "The email address is invalid.",
"passwordRequired": "The password is required.",
"passwordMismatch": "The passwords do not match!",
"invalidPassword": "The provided password is invalid.",
"invalidDisplayName": "The provided display name is invalid.",
- "otpFailed": "The code you entered was invalid. Please note that you will be given a new QR code for subsequent retries."
+ "otpFailed": "The code you entered was invalid. Please note that you will be given a new QR code for subsequent retries.",
+ "required": "Please fill in the required fields",
+ "existingRegistration": "The username or email address is already in use.",
+ "activationFailed": "Your account could not be activated - the URL might have been used or expired already."
},
"otp": {
"title": "Two-factor authentication",
diff --git a/src/lib/i18n/en/common.json b/src/lib/i18n/en/common.json
index a4c56c5..12b84c6 100644
--- a/src/lib/i18n/en/common.json
+++ b/src/lib/i18n/en/common.json
@@ -1,10 +1,10 @@
{
- "siteName": "Icy Network",
- "description": "Icy Network is a Single-Sign-On service used by other applications.",
+ "description": "{{siteName}} is a Single-Sign-On service used by other applications.",
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is completely open source and can be audited by anyone.",
"submit": "Submit",
"cancel": "Cancel",
"manage": "Manage",
"back": "Go back",
- "home": "Home page"
+ "home": "Home page",
+ "required": "Required fields"
}
diff --git a/src/lib/i18n/en/oauth2.json b/src/lib/i18n/en/oauth2.json
index fec6a4f..a0035bb 100644
--- a/src/lib/i18n/en/oauth2.json
+++ b/src/lib/i18n/en/oauth2.json
@@ -11,8 +11,7 @@
"email": "Your email address",
"picture": "Your profile picture",
"account": "Changing your password or other account settings",
- "management": "Manage Icy Network on your behalf",
- "admin": "Commit administrative actions to the extent of your user privileges"
+ "management": "Commit administrative actions to the extent of your user privileges"
},
"link": {
"website": "Website",
diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts
index f654f3e..55dd7af 100644
--- a/src/lib/i18n/index.ts
+++ b/src/lib/i18n/index.ts
@@ -1,6 +1,10 @@
-import i18n from 'sveltekit-i18n';
+import i18n, { type Config } from 'sveltekit-i18n';
-const config = {
+interface Params {
+ siteName: string;
+}
+
+const config: Config = {
loaders: [
{
locale: 'en',
diff --git a/src/lib/server/drizzle/migrations/relations.ts b/src/lib/server/drizzle/migrations/relations.ts
deleted file mode 100644
index b0ceb00..0000000
--- a/src/lib/server/drizzle/migrations/relations.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { relations } from 'drizzle-orm/relations';
-import {
- user,
- auditLog,
- document,
- oauth2Client,
- upload,
- oauth2ClientAuthorization,
- oauth2ClientUrl,
- oauth2Token,
- userPrivilegesPrivilege,
- privilege,
- userToken
-} from '../schema';
-
-export const auditLogRelations = relations(auditLog, ({ one }) => ({
- user: one(user, {
- fields: [auditLog.actorId],
- references: [user.id]
- })
-}));
-
-export const userRelations = relations(user, ({ one, many }) => ({
- audit_logs: many(auditLog),
- documents: many(document),
- o_auth2_clients: many(oauth2Client),
- o_auth2_client_authorizations: many(oauth2ClientAuthorization),
- o_auth2_tokens: many(oauth2Token),
- uploads: many(upload, {
- relationName: 'upload_uploaderId_user_id'
- }),
- upload: one(upload, {
- fields: [user.pictureId],
- references: [upload.id],
- relationName: 'user_pictureId_upload_id'
- }),
- user_privileges_privileges: many(userPrivilegesPrivilege),
- user_tokens: many(userToken)
-}));
-
-export const documentRelations = relations(document, ({ one }) => ({
- user: one(user, {
- fields: [document.authorId],
- references: [user.id]
- })
-}));
-
-export const oauth2ClientRelations = relations(oauth2Client, ({ one, many }) => ({
- user: one(user, {
- fields: [oauth2Client.ownerId],
- references: [user.id]
- }),
- upload: one(upload, {
- fields: [oauth2Client.pictureId],
- references: [upload.id]
- }),
- o_auth2_client_authorizations: many(oauth2ClientAuthorization),
- o_auth2_client_urls: many(oauth2ClientUrl),
- o_auth2_tokens: many(oauth2Token)
-}));
-
-export const uploadRelations = relations(upload, ({ one, many }) => ({
- o_auth2_clients: many(oauth2Client),
- user: one(user, {
- fields: [upload.uploaderId],
- references: [user.id],
- relationName: 'upload_uploaderId_user_id'
- }),
- users: many(user, {
- relationName: 'user_pictureId_upload_id'
- })
-}));
-
-export const oauth2ClientAuthorizationRelations = relations(
- oauth2ClientAuthorization,
- ({ one }) => ({
- user: one(user, {
- fields: [oauth2ClientAuthorization.userId],
- references: [user.id]
- }),
- o_auth2_client: one(oauth2Client, {
- fields: [oauth2ClientAuthorization.clientId],
- references: [oauth2Client.id]
- })
- })
-);
-
-export const oauth2ClientUrlRelations = relations(oauth2ClientUrl, ({ one }) => ({
- o_auth2_client: one(oauth2Client, {
- fields: [oauth2ClientUrl.clientId],
- references: [oauth2Client.id]
- })
-}));
-
-export const oauth2TokenRelations = relations(oauth2Token, ({ one }) => ({
- o_auth2_client: one(oauth2Client, {
- fields: [oauth2Token.clientId],
- references: [oauth2Client.id]
- }),
- user: one(user, {
- fields: [oauth2Token.userId],
- references: [user.id]
- })
-}));
-
-export const userPrivilegesPrivilegeRelations = relations(
- userPrivilegesPrivilege,
- ({ one }) => ({
- user: one(user, {
- fields: [userPrivilegesPrivilege.userId],
- references: [user.id]
- }),
- privilege: one(privilege, {
- fields: [userPrivilegesPrivilege.privilegeId],
- references: [privilege.id]
- })
- })
-);
-
-export const privilegeRelations = relations(privilege, ({ many }) => ({
- user_privileges_privileges: many(userPrivilegesPrivilege)
-}));
-
-export const userTokenRelations = relations(userToken, ({ one }) => ({
- user: one(user, {
- fields: [userToken.userId],
- references: [user.id]
- })
-}));
diff --git a/src/lib/server/drizzle/schema.ts b/src/lib/server/drizzle/schema.ts
index 31ef498..4a54ae0 100644
--- a/src/lib/server/drizzle/schema.ts
+++ b/src/lib/server/drizzle/schema.ts
@@ -1,4 +1,4 @@
-import { sql } from 'drizzle-orm';
+import { relations, sql } from 'drizzle-orm';
import {
mysqlTable,
int,
@@ -225,3 +225,117 @@ export const userToken = mysqlTable('user_token', {
.default(sql`current_timestamp(6)`)
.notNull()
});
+
+export type UserToken = typeof userToken.$inferSelect;
+
+export const auditLogRelations = relations(auditLog, ({ one }) => ({
+ user: one(user, {
+ fields: [auditLog.actorId],
+ references: [user.id]
+ })
+}));
+
+export const userRelations = relations(user, ({ one, many }) => ({
+ audit_logs: many(auditLog),
+ documents: many(document),
+ o_auth2_clients: many(oauth2Client),
+ o_auth2_client_authorizations: many(oauth2ClientAuthorization),
+ o_auth2_tokens: many(oauth2Token),
+ uploads: many(upload, {
+ relationName: 'upload_uploaderId_user_id'
+ }),
+ upload: one(upload, {
+ fields: [user.pictureId],
+ references: [upload.id],
+ relationName: 'user_pictureId_upload_id'
+ }),
+ user_privileges_privileges: many(userPrivilegesPrivilege),
+ user_tokens: many(userToken)
+}));
+
+export const documentRelations = relations(document, ({ one }) => ({
+ user: one(user, {
+ fields: [document.authorId],
+ references: [user.id]
+ })
+}));
+
+export const oauth2ClientRelations = relations(oauth2Client, ({ one, many }) => ({
+ user: one(user, {
+ fields: [oauth2Client.ownerId],
+ references: [user.id]
+ }),
+ upload: one(upload, {
+ fields: [oauth2Client.pictureId],
+ references: [upload.id]
+ }),
+ o_auth2_client_authorizations: many(oauth2ClientAuthorization),
+ o_auth2_client_urls: many(oauth2ClientUrl),
+ o_auth2_tokens: many(oauth2Token)
+}));
+
+export const uploadRelations = relations(upload, ({ one, many }) => ({
+ o_auth2_clients: many(oauth2Client),
+ user: one(user, {
+ fields: [upload.uploaderId],
+ references: [user.id],
+ relationName: 'upload_uploaderId_user_id'
+ }),
+ users: many(user, {
+ relationName: 'user_pictureId_upload_id'
+ })
+}));
+
+export const oauth2ClientAuthorizationRelations = relations(
+ oauth2ClientAuthorization,
+ ({ one }) => ({
+ user: one(user, {
+ fields: [oauth2ClientAuthorization.userId],
+ references: [user.id]
+ }),
+ o_auth2_client: one(oauth2Client, {
+ fields: [oauth2ClientAuthorization.clientId],
+ references: [oauth2Client.id]
+ })
+ })
+);
+
+export const oauth2ClientUrlRelations = relations(oauth2ClientUrl, ({ one }) => ({
+ o_auth2_client: one(oauth2Client, {
+ fields: [oauth2ClientUrl.clientId],
+ references: [oauth2Client.id]
+ })
+}));
+
+export const oauth2TokenRelations = relations(oauth2Token, ({ one }) => ({
+ o_auth2_client: one(oauth2Client, {
+ fields: [oauth2Token.clientId],
+ references: [oauth2Client.id]
+ }),
+ user: one(user, {
+ fields: [oauth2Token.userId],
+ references: [user.id]
+ })
+}));
+
+export const userPrivilegesPrivilegeRelations = relations(userPrivilegesPrivilege, ({ one }) => ({
+ user: one(user, {
+ fields: [userPrivilegesPrivilege.userId],
+ references: [user.id]
+ }),
+ privilege: one(privilege, {
+ fields: [userPrivilegesPrivilege.privilegeId],
+ references: [privilege.id]
+ })
+}));
+
+export const privilegeRelations = relations(privilege, ({ many }) => ({
+ user_privileges_privileges: many(userPrivilegesPrivilege)
+}));
+
+export const userTokenRelations = relations(userToken, ({ one }) => ({
+ user: one(user, {
+ fields: [userToken.userId],
+ references: [user.id]
+ })
+}));
diff --git a/src/lib/server/email/index.ts b/src/lib/server/email/index.ts
new file mode 100644
index 0000000..1a6361e
--- /dev/null
+++ b/src/lib/server/email/index.ts
@@ -0,0 +1,64 @@
+import {
+ EMAIL_ENABLED,
+ EMAIL_FROM,
+ EMAIL_SMTP_HOST,
+ EMAIL_SMTP_PASS,
+ EMAIL_SMTP_PORT,
+ EMAIL_SMTP_SECURE,
+ EMAIL_SMTP_USER
+} from '$env/static/private';
+import nodemailer from 'nodemailer';
+import type { EmailTemplate } from './template.interface';
+
+export class Emails {
+ public transport?: nodemailer.Transporter;
+ public static from = EMAIL_FROM;
+
+ static sender: Emails;
+
+ static getSender() {
+ if (!Emails.sender) {
+ Emails.sender = new Emails();
+ }
+ return Emails.sender;
+ }
+
+ constructor() {
+ this.createTransport();
+ }
+
+ public async send(
+ to: string,
+ subject: string,
+ text: string,
+ html?: string,
+ from = Emails.from
+ ): Promise {
+ return this.transport?.sendMail({
+ to,
+ subject,
+ text,
+ html,
+ from
+ });
+ }
+
+ public async sendTemplate(to: string, subject: string, message: EmailTemplate): Promise {
+ return this.send(to, subject, message.text, message.html);
+ }
+
+ protected createTransport() {
+ if (EMAIL_ENABLED !== 'true') return;
+ this.transport = nodemailer.createTransport({
+ host: EMAIL_SMTP_HOST,
+ port: Number(EMAIL_SMTP_PORT) || undefined,
+ secure: EMAIL_SMTP_SECURE === 'true',
+ auth: {
+ user: EMAIL_SMTP_USER,
+ pass: EMAIL_SMTP_PASS
+ }
+ });
+ }
+}
+
+export * from './templates';
diff --git a/src/lib/server/email/template.interface.ts b/src/lib/server/email/template.interface.ts
new file mode 100644
index 0000000..4baea66
--- /dev/null
+++ b/src/lib/server/email/template.interface.ts
@@ -0,0 +1,4 @@
+export interface EmailTemplate {
+ text: string;
+ html: string;
+}
diff --git a/src/lib/server/email/templates/forgot-password.email.ts b/src/lib/server/email/templates/forgot-password.email.ts
new file mode 100644
index 0000000..2d30ebc
--- /dev/null
+++ b/src/lib/server/email/templates/forgot-password.email.ts
@@ -0,0 +1,27 @@
+import { PUBLIC_SITE_NAME } from '$env/static/public';
+import type { EmailTemplate } from '../template.interface';
+
+export const ForgotPasswordEmail = (username: string, url: string): EmailTemplate => ({
+ text: `
+${PUBLIC_SITE_NAME}
+
+Hello, ${username}! You have requested a password reset on ${PUBLIC_SITE_NAME}.
+
+In order to change your password, please click on the following link.
+
+Change your password: ${url}
+
+If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.
+ `,
+ html: /* html */ `
+${PUBLIC_SITE_NAME}
+
+Hello, ${username}! You have requested a password reset on ${PUBLIC_SITE_NAME}.
+
+In order to change your password, please click on the following link.
+
+Change your password: ${url}
+
+If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.
+ `
+});
diff --git a/src/lib/server/email/templates/index.ts b/src/lib/server/email/templates/index.ts
new file mode 100644
index 0000000..33f22bd
--- /dev/null
+++ b/src/lib/server/email/templates/index.ts
@@ -0,0 +1,3 @@
+export * from './forgot-password.email';
+export * from './invitation.email';
+export * from './registration.email';
diff --git a/src/lib/server/email/templates/invitation.email.ts b/src/lib/server/email/templates/invitation.email.ts
new file mode 100644
index 0000000..a654e3c
--- /dev/null
+++ b/src/lib/server/email/templates/invitation.email.ts
@@ -0,0 +1,23 @@
+import { PUBLIC_SITE_NAME } from '$env/static/public';
+import type { EmailTemplate } from '../template.interface';
+
+export const InvitationEmail = (url: string): EmailTemplate => ({
+ text: `
+${PUBLIC_SITE_NAME}
+
+Please click on the following link to create an account on ${PUBLIC_SITE_NAME}.
+
+Create your account here: ${url}
+
+This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.
+ `,
+ html: /* html */ `
+${PUBLIC_SITE_NAME}
+
+Please click on the following link to create an account on ${PUBLIC_SITE_NAME}.
+
+Create your account here: ${url}
+
+This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.
+ `
+});
diff --git a/src/lib/server/email/templates/registration.email.ts b/src/lib/server/email/templates/registration.email.ts
new file mode 100644
index 0000000..82f6f97
--- /dev/null
+++ b/src/lib/server/email/templates/registration.email.ts
@@ -0,0 +1,27 @@
+import { PUBLIC_SITE_NAME } from '$env/static/public';
+import type { EmailTemplate } from '../template.interface';
+
+export const RegistrationEmail = (username: string, url: string): EmailTemplate => ({
+ text: `
+${PUBLIC_SITE_NAME}
+
+Welcome to ${PUBLIC_SITE_NAME}, ${username}!
+
+In order to proceed with logging in, please click on the following link to activate your account.
+
+Activate your account: ${url}
+
+This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.
+ `,
+ html: /* html */ `
+${PUBLIC_SITE_NAME}
+
+Welcome to ${PUBLIC_SITE_NAME}, ${username}!
+
+In order to proceed with logging in, please click on the following link to activate your account.
+
+Activate your account: ${url}
+
+This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.
+ `
+});
diff --git a/src/lib/server/oauth2/model/client.ts b/src/lib/server/oauth2/model/client.ts
index c885e47..4d91523 100644
--- a/src/lib/server/oauth2/model/client.ts
+++ b/src/lib/server/oauth2/model/client.ts
@@ -124,7 +124,7 @@ export class OAuth2Clients {
});
if (scope.includes('management')) {
- allowedScopes.push('management', 'admin');
+ allowedScopes.push('management');
}
// TODO: client picture
diff --git a/src/lib/server/users/index.ts b/src/lib/server/users/index.ts
index c13c885..fd4c330 100644
--- a/src/lib/server/users/index.ts
+++ b/src/lib/server/users/index.ts
@@ -1,8 +1,13 @@
import bcrypt from 'bcryptjs';
-import { and, eq, or } from 'drizzle-orm';
-import { db, user, type User } from '../drizzle';
+import { and, eq, gt, or, sql } from 'drizzle-orm';
+import { db, user, userToken, type User } from '../drizzle';
import type { UserSession } from './types';
import { redirect } from '@sveltejs/kit';
+import { CryptoUtils } from '../crypto-utils';
+import { EMAIL_ENABLED } from '$env/static/private';
+import { Emails, ForgotPasswordEmail, InvitationEmail, RegistrationEmail } from '../email';
+import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
+import { UserTokens } from './tokens';
export class Users {
static async getById(id: number): Promise {
@@ -71,6 +76,172 @@ export class Users {
}
return currentUser;
}
+
+ static async checkRegistration(username: string, email: string) {
+ return !(
+ await db
+ .select({ id: user.id })
+ .from(user)
+ .where(
+ or(
+ sql`lower(${user.username}) = ${username.toLowerCase()}`,
+ sql`lower(${user.email}) = ${email.toLowerCase()}`
+ )
+ )
+ )?.length;
+ }
+
+ static async getActivationToken(token: string) {
+ const [returnedToken] = await db
+ .select({ id: userToken.id, token: userToken.token, userId: userToken.userId })
+ .from(userToken)
+ .where(
+ and(
+ eq(userToken.token, token),
+ eq(userToken.type, 'activation'),
+ gt(userToken.expires_at, new Date())
+ )
+ )
+ .limit(1);
+ if (!returnedToken?.userId) return undefined;
+
+ const [userInfo] = await db
+ .select()
+ .from(user)
+ .where(eq(user.id, returnedToken.userId as number));
+
+ return {
+ ...returnedToken,
+ user: userInfo
+ };
+ }
+
+ static async activateUserBy(token: string, subject: User) {
+ await db
+ .update(user)
+ .set({ activated: 1, activity_at: new Date() })
+ .where(eq(user.id, subject.id));
+ await UserTokens.remove(token);
+ }
+
+ static async getPasswordToken(token: string) {
+ return db.query.userToken.findFirst({
+ columns: {
+ id: true,
+ token: true
+ },
+ where: and(
+ eq(userToken.token, token),
+ eq(userToken.type, 'password'),
+ gt(userToken.expires_at, new Date())
+ ),
+ with: {
+ user: true
+ }
+ });
+ }
+
+ static async register({
+ username,
+ displayName,
+ email,
+ password,
+ activate = false
+ }: {
+ username: string;
+ displayName: string;
+ email: string;
+ password: string;
+ activate?: boolean;
+ }) {
+ const passwordHash = await Users.hashPassword(password);
+ const [retval] = await db.insert(user).values({
+ uuid: CryptoUtils.createUUID(),
+ email,
+ username,
+ password: passwordHash,
+ display_name: displayName,
+ activated: EMAIL_ENABLED === 'false' ? 1 : Number(activate),
+ activity_at: new Date()
+ });
+
+ const [newUser] = await db.select().from(user).where(eq(user.id, retval.insertId));
+
+ if (EMAIL_ENABLED !== 'false' && !activate) {
+ await Users.sendRegistrationEmail(newUser);
+ }
+
+ // TODO: audit log
+
+ return newUser;
+ }
+
+ static async sendRegistrationEmail(user: User) {
+ const token = await UserTokens.create(
+ 'activation',
+ new Date(Date.now() + 3600 * 1000),
+ user.id
+ );
+
+ const params = new URLSearchParams({ activate: token.token });
+ const content = RegistrationEmail(user.username, `${PUBLIC_URL}/login?${params.toString()}`);
+
+ // TODO: logging
+ try {
+ await Emails.getSender().sendTemplate(
+ user.email,
+ `Activate your account on ${PUBLIC_SITE_NAME}`,
+ content
+ );
+ } catch (error) {
+ console.error(error);
+ await UserTokens.remove(token);
+ }
+ }
+
+ static async sendPasswordEmail(user: User) {
+ const token = await UserTokens.create('password', new Date(Date.now() + 3600 * 1000), user.id);
+ const params = new URLSearchParams({ token: token.token });
+ const content = ForgotPasswordEmail(
+ user.username,
+ `${PUBLIC_URL}/login/password?${params.toString()}`
+ );
+
+ // TODO: logging
+ try {
+ await Emails.getSender().sendTemplate(
+ user.email,
+ `Reset your password on ${PUBLIC_SITE_NAME}`,
+ content
+ );
+ } catch {
+ await UserTokens.remove(token);
+ }
+ }
+
+ static async sendInvitationEmail(email: string) {
+ const token = await UserTokens.create(
+ 'login',
+ new Date(Date.now() + 3600 * 1000),
+ undefined,
+ email
+ );
+ const params = new URLSearchParams({ token: token.token });
+ const content = InvitationEmail(`${PUBLIC_URL}/register?${params.toString()}`);
+
+ // TODO: logging
+ try {
+ await Emails.getSender().sendTemplate(
+ email,
+ `You have been invited to create an account on ${PUBLIC_SITE_NAME}`,
+ content
+ );
+ } catch {
+ await UserTokens.remove(token);
+ }
+ }
}
+export * from './totp';
export * from './types';
+export * from './tokens';
diff --git a/src/lib/server/users/tokens.ts b/src/lib/server/users/tokens.ts
new file mode 100644
index 0000000..1abbfd2
--- /dev/null
+++ b/src/lib/server/users/tokens.ts
@@ -0,0 +1,28 @@
+import { eq } from 'drizzle-orm';
+import { CryptoUtils } from '../crypto-utils';
+import { db, userToken, type UserToken } from '../drizzle';
+
+export class UserTokens {
+ static async create(
+ type: (typeof userToken.$inferInsert)['type'],
+ expires: Date,
+ userId?: number,
+ nonce?: string
+ ) {
+ const token = CryptoUtils.generateString(64);
+ const obj = {
+ type,
+ token,
+ userId,
+ expires_at: expires,
+ nonce
+ };
+ const [retval] = await db.insert(userToken).values(obj);
+ return { id: retval.insertId, ...obj } as UserToken;
+ }
+
+ static async remove(token: string | { token: string }) {
+ const removeBy = typeof token === 'string' ? token : token.token;
+ await db.delete(userToken).where(eq(userToken.token, removeBy));
+ }
+}
diff --git a/src/lib/server/users/totp.ts b/src/lib/server/users/totp.ts
index f210ad2..1cd5bda 100644
--- a/src/lib/server/users/totp.ts
+++ b/src/lib/server/users/totp.ts
@@ -1,6 +1,7 @@
import { authenticator as totp } from 'otplib';
import { db, userToken, type User } from '../drizzle';
import { and, eq, gt, isNull, or } from 'drizzle-orm';
+import { PUBLIC_SITE_NAME } from '$env/static/public';
totp.options = {
window: 2
@@ -12,7 +13,7 @@ export class TimeOTP {
}
public static getUri(secret: string, username: string): string {
- return totp.keyuri(username, 'Icy Network', secret);
+ return totp.keyuri(username, PUBLIC_SITE_NAME, secret);
}
public static createSecret(): string {
diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte
index c85de51..3efa4b0 100644
--- a/src/routes/account/+page.svelte
+++ b/src/routes/account/+page.svelte
@@ -15,6 +15,7 @@
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
import { writable } from 'svelte/store';
+ import { PUBLIC_SITE_NAME } from '$env/static/public';
export let data: PageData;
export let form: ActionData;
@@ -51,8 +52,12 @@
};
+
+ {$t('account.title')} - {PUBLIC_SITE_NAME}
+
+
- {$t('common.siteName')}
+ {PUBLIC_SITE_NAME}
{$t('account.title')}
@@ -93,6 +98,7 @@
{$t('account.displayName')}
{$t('account.currentEmail')}
-
+
{$t('account.newEmail')}
-
+
@@ -136,7 +147,7 @@
- {$t('account.repeatPassword')}
+ {$t('account.repeatNewPassword')}
- {$t('common.siteName')}
+ {PUBLIC_SITE_NAME}
{$t('account.otp.title')}
{#if form?.success}
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts
index ff8c544..2fb348b 100644
--- a/src/routes/login/+page.server.ts
+++ b/src/routes/login/+page.server.ts
@@ -93,3 +93,32 @@ export const actions = {
return redirect(303, redirectUrl);
}
} as Actions;
+
+export const load = async ({ locals, url }) => {
+ if (locals.session.data?.user) {
+ return redirect(301, url.searchParams.get('redirectTo') || '/');
+ }
+
+ // Activation routine
+ if (url.searchParams.has('activate')) {
+ const activationInfo = await Users.getActivationToken(
+ url.searchParams.get('activate') as string
+ );
+
+ if (!activationInfo?.user) {
+ return {
+ activated: false
+ };
+ }
+
+ await Users.activateUserBy(activationInfo.token, activationInfo.user);
+
+ return {
+ activated: true
+ };
+ }
+
+ return {
+ activated: null
+ };
+};
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
index 0dcc1fa..8000bc0 100644
--- a/src/routes/login/+page.svelte
+++ b/src/routes/login/+page.svelte
@@ -1,5 +1,7 @@
+
+ {$t('account.login.title')} - {PUBLIC_SITE_NAME}
+
+
- {$t('common.siteName')}
+ {PUBLIC_SITE_NAME}
{$t('account.login.title')}
{/if}
{$t('account.login.submit')}
+
-
{$t('common.description')}
+
{$t('common.description', { siteName: PUBLIC_SITE_NAME })}
{@html $t('common.cookieDisclaimer')}
diff --git a/src/routes/oauth2/authorize/+page.svelte b/src/routes/oauth2/authorize/+page.svelte
index b69dce5..f7daf23 100644
--- a/src/routes/oauth2/authorize/+page.svelte
+++ b/src/routes/oauth2/authorize/+page.svelte
@@ -1,5 +1,6 @@
+
+ {$t('oauth2.authorize.title')} "{data.client?.title || ''}" - {PUBLIC_SITE_NAME}
+
+
{#if data.error}
- {$t('common.siteName')}
+ {PUBLIC_SITE_NAME}
{$t('oauth2.authorize.errorPage')}{$t('common.siteName')}
+ {PUBLIC_SITE_NAME}
{$t('oauth2.authorize.title')}
diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts
new file mode 100644
index 0000000..54b7bc2
--- /dev/null
+++ b/src/routes/register/+page.server.ts
@@ -0,0 +1,107 @@
+import { REGISTRATIONS } from '$env/static/private';
+import { Changesets } from '$lib/server/changesets.js';
+import { Users } from '$lib/server/users/index.js';
+import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
+import { fail, redirect } from '@sveltejs/kit';
+
+interface RegisterData {
+ username: string;
+ displayName: string;
+ email: string;
+ password: string;
+}
+
+const fields: (keyof RegisterData)[] = ['username', 'displayName', 'email', 'password'];
+
+export const actions = {
+ default: async ({ request, locals }) => {
+ // Logged in users cannot make more accounts
+ if (locals.session.data?.user || REGISTRATIONS === 'false') {
+ return redirect(303, '/');
+ }
+
+ const body = await request.formData();
+ const changes = Changesets.take
(fields, body);
+ const { username, displayName, email, password } = changes;
+ // Each field must be present
+ if (!username || !displayName || !email || !password) {
+ return fail(400, {
+ username,
+ displayName,
+ email,
+ errors: ['required'],
+ fields: fields.reduce(
+ (missing, field) => (!changes[field] ? [...missing, field] : missing),
+ []
+ )
+ });
+ }
+
+ if (!usernameRegex.test(username)) {
+ return fail(400, {
+ username,
+ displayName,
+ email,
+ errors: ['invalidUsername'],
+ fields: ['username']
+ });
+ }
+
+ if (displayName.length < 3 || displayName.length > 32) {
+ return fail(400, {
+ username,
+ displayName,
+ email,
+ errors: ['invalidDisplayName'],
+ fields: ['displayName']
+ });
+ }
+
+ if (!emailRegex.test(email)) {
+ return fail(400, {
+ username,
+ displayName,
+ email,
+ errors: ['invalidEmail'],
+ fields: ['email']
+ });
+ }
+
+ if (!passwordRegex.test(password)) {
+ return fail(400, {
+ username,
+ displayName,
+ email,
+ errors: ['invalidPassword'],
+ fields: ['password']
+ });
+ }
+
+ if (!(await Users.checkRegistration(username, email))) {
+ return fail(400, {
+ username,
+ displayName,
+ email,
+ errors: ['existingRegistration'],
+ fields: ['username', 'email']
+ });
+ }
+
+ // TODO: check for registration token
+ const newUser = await Users.register({ username, displayName, password, email });
+
+ return {
+ success: newUser.activated ? 'userCreated' : 'emailSent'
+ };
+ }
+};
+
+export const load = ({ locals }) => {
+ if (locals.session.data?.user) {
+ return redirect(301, '/');
+ }
+
+ return {
+ enabled: REGISTRATIONS === 'true'
+ };
+};
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte
new file mode 100644
index 0000000..4230a9b
--- /dev/null
+++ b/src/routes/register/+page.svelte
@@ -0,0 +1,134 @@
+
+
+
+ {$t('account.register.title')} - {PUBLIC_SITE_NAME}
+
+
+
+ {PUBLIC_SITE_NAME}
+ {$t('account.register.title')}
+
+
+ {#if !data.enabled}
+ {$t('account.register.disabled')}
+ {:else if form?.success}
+ {$t(`account.register.${form.success}`)}
+ {:else}
+
+ {/if}
+
+
+