client invites
This commit is contained in:
parent
243fe982b1
commit
dc9be2016b
3
migrations/0002_whole_vivisector.sql
Normal file
3
migrations/0002_whole_vivisector.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TABLE `user_token` MODIFY COLUMN `type` enum('generic','activation','deactivation','password','login','gdpr','totp','public_key','invite','recovery') NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `privilege` ADD `automatic` tinyint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `user_token` ADD `metadata` text;
|
1064
migrations/meta/0002_snapshot.json
Normal file
1064
migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,13 @@
|
||||
"when": 1717232859846,
|
||||
"tag": "0001_redundant_layla_miller",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1717344670405,
|
||||
"tag": "0002_whole_vivisector",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
@ -105,6 +105,12 @@
|
||||
"account": "Change user account settings",
|
||||
"openid": "Get an ID token JWT (OpenID Connect)"
|
||||
},
|
||||
"managers": {
|
||||
"title": "Application members",
|
||||
"hint": "These users can edit this application just like you can, except that they cannot regenerate the secret or delete the application. Please note that they must have an active account on {{siteName}}, but not necessarily with the provided address.",
|
||||
"add": "Invite a new member",
|
||||
"invite": "Invite"
|
||||
},
|
||||
"errors": {
|
||||
"noRedirect": "At least one Redirect URI is required for you to be able to use this application!",
|
||||
"forbidden": "This action is forbidden for this user.",
|
||||
@ -116,6 +122,8 @@
|
||||
"invalidUrl": "Invalid URL provided.",
|
||||
"invalidPrivilegeId": "Invalid privilege ID for deletion.",
|
||||
"invalidPrivilege": "Invalid privilege provided.",
|
||||
"invalidEmail": "Invalid email address.",
|
||||
"emailExists": "This email address is already added.",
|
||||
"noFile": "Please upload a file first."
|
||||
}
|
||||
}
|
||||
|
@ -5,4 +5,13 @@ export class ApiUtils {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
static redirect(url: string, status = 302): Response {
|
||||
return new Response(null, {
|
||||
status,
|
||||
headers: {
|
||||
Location: url
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -145,7 +145,8 @@ export type NewOAuth2Token = typeof oauth2Token.$inferInsert;
|
||||
export const privilege = mysqlTable('privilege', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
name: text('name').notNull(),
|
||||
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' })
|
||||
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
|
||||
automatic: tinyint('automatic').default(0).notNull()
|
||||
});
|
||||
|
||||
export type Privilege = typeof privilege.$inferSelect;
|
||||
@ -235,11 +236,13 @@ export const userToken = mysqlTable('user_token', {
|
||||
'gdpr',
|
||||
'totp',
|
||||
'public_key',
|
||||
'invite',
|
||||
'recovery'
|
||||
]).notNull(),
|
||||
expires_at: timestamp('expires_at', { mode: 'date' }),
|
||||
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||
nonce: text('nonce'),
|
||||
metadata: text('metadata'),
|
||||
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||
.default(sql`current_timestamp(6)`)
|
||||
.notNull()
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from './forgot-password.email';
|
||||
export * from './invitation.email';
|
||||
export * from './oauth2-invitation.email';
|
||||
export * from './registration.email';
|
||||
|
31
src/lib/server/email/templates/oauth2-invitation.email.ts
Normal file
31
src/lib/server/email/templates/oauth2-invitation.email.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import type { EmailTemplate } from '../template.interface';
|
||||
|
||||
export const OAuth2InvitationEmail = (
|
||||
inviter: string,
|
||||
clientName: string,
|
||||
url: string
|
||||
): EmailTemplate => ({
|
||||
text: `
|
||||
${PUBLIC_SITE_NAME}
|
||||
|
||||
${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
|
||||
|
||||
Please click on the following link to accept the invitation.
|
||||
|
||||
Accept invitation: ${url}
|
||||
|
||||
This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.
|
||||
`,
|
||||
html: /* html */ `
|
||||
<h1>${PUBLIC_SITE_NAME}</h1>
|
||||
|
||||
<p>${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
|
||||
|
||||
<p><b>Please click on the following link to accept the invitation ${PUBLIC_SITE_NAME}.</b></p>
|
||||
|
||||
<p>Accept invitation: <a href="${url}" target="_blank">${url}</a></p>
|
||||
|
||||
<p>This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.</p>
|
||||
`
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
import { PUBLIC_URL, PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import { CryptoUtils } from '$lib/server/crypto-utils';
|
||||
import {
|
||||
db,
|
||||
@ -12,7 +13,9 @@ import {
|
||||
type OAuth2ClientUrl,
|
||||
type User
|
||||
} from '$lib/server/drizzle';
|
||||
import { Emails, OAuth2InvitationEmail } from '$lib/server/email';
|
||||
import { Uploads } from '$lib/server/upload';
|
||||
import { UserTokens } from '$lib/server/users';
|
||||
import type { PaginationMeta } from '$lib/types';
|
||||
import { and, count, eq, like, or, sql } from 'drizzle-orm';
|
||||
|
||||
@ -38,6 +41,7 @@ export interface OAuth2AuthorizedUser {
|
||||
}
|
||||
|
||||
export interface OAuth2ManagerUser {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
@ -369,6 +373,57 @@ export class OAuth2Clients {
|
||||
await db.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id));
|
||||
}
|
||||
|
||||
static async getManagers(client: OAuth2Client) {
|
||||
return await db
|
||||
.select({ id: oauth2ClientManager.id, email: user.email })
|
||||
.from(oauth2ClientManager)
|
||||
.innerJoin(user, eq(user.id, oauth2ClientManager.userId))
|
||||
.where(eq(oauth2ClientManager.clientId, client.id));
|
||||
}
|
||||
|
||||
static async addManager(client: OAuth2Client, actor: User, subject: User) {
|
||||
await db.insert(oauth2ClientManager).values({
|
||||
clientId: client.id,
|
||||
userId: subject.id,
|
||||
issuerId: actor.id
|
||||
});
|
||||
}
|
||||
|
||||
static async removeManager(client: OAuth2Client, managerId: number) {
|
||||
await db
|
||||
.delete(oauth2ClientManager)
|
||||
.where(
|
||||
and(eq(oauth2ClientManager.clientId, client.id), eq(oauth2ClientManager.id, managerId))
|
||||
);
|
||||
}
|
||||
|
||||
static async sendManagerInvitationEmail(client: OAuth2Client, actor: User, email: string) {
|
||||
const token = await UserTokens.create(
|
||||
'invite',
|
||||
new Date(Date.now() + 3600 * 1000),
|
||||
actor.id,
|
||||
undefined,
|
||||
`clientmanager=${client.client_id}`
|
||||
);
|
||||
const params = new URLSearchParams({ token: token.token });
|
||||
const content = OAuth2InvitationEmail(
|
||||
actor.display_name,
|
||||
client.title,
|
||||
`${PUBLIC_URL}/ssoadmin/oauth2/invite?${params.toString()}`
|
||||
);
|
||||
|
||||
// TODO: logging
|
||||
try {
|
||||
await Emails.getSender().sendTemplate(
|
||||
email,
|
||||
`You have been invited to manage "${client.title}" on ${PUBLIC_SITE_NAME}`,
|
||||
content
|
||||
);
|
||||
} catch {
|
||||
await UserTokens.remove(token);
|
||||
}
|
||||
}
|
||||
|
||||
static async authorizeClientInfo(client: OAuth2Client, scope: string[]) {
|
||||
const links = await OAuth2Clients.getClientUrls(client);
|
||||
const filteredLinks = links
|
||||
|
@ -194,10 +194,11 @@ export class Users {
|
||||
|
||||
static async sendInvitationEmail(email: string) {
|
||||
const token = await UserTokens.create(
|
||||
'login',
|
||||
'invite',
|
||||
new Date(Date.now() + 3600 * 1000),
|
||||
undefined,
|
||||
email
|
||||
undefined,
|
||||
`register=${email}`
|
||||
);
|
||||
const params = new URLSearchParams({ token: token.token });
|
||||
const content = InvitationEmail(`${PUBLIC_URL}/register?${params.toString()}`);
|
||||
|
@ -7,7 +7,8 @@ export class UserTokens {
|
||||
type: (typeof userToken.$inferInsert)['type'],
|
||||
expires: Date,
|
||||
userId?: number,
|
||||
nonce?: string
|
||||
nonce?: string,
|
||||
metadata?: string
|
||||
) {
|
||||
const token = CryptoUtils.generateString(64);
|
||||
const obj = <typeof userToken.$inferInsert>{
|
||||
@ -15,7 +16,8 @@ export class UserTokens {
|
||||
token,
|
||||
userId,
|
||||
expires_at: expires,
|
||||
nonce
|
||||
nonce,
|
||||
metadata
|
||||
};
|
||||
const [retval] = await db.insert(userToken).values(obj);
|
||||
return { id: retval.insertId, ...obj } as UserToken;
|
||||
|
@ -7,7 +7,7 @@ import { Uploads } from '$lib/server/upload.js';
|
||||
import { Users } from '$lib/server/users';
|
||||
import { UsersAdmin } from '$lib/server/users/admin';
|
||||
import { hasPrivileges } from '$lib/utils';
|
||||
import { privilegeRegex } from '$lib/validators.js';
|
||||
import { emailRegex, privilegeRegex } from '$lib/validators.js';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
interface AddUrlRequest {
|
||||
@ -26,6 +26,10 @@ interface AddPrivilegeRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InviteRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
@ -380,6 +384,69 @@ export const actions = {
|
||||
|
||||
await Uploads.removeClientAvatar(details as OAuth2Client);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
invite: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
['admin:oauth2', 'self:oauth2']
|
||||
]);
|
||||
|
||||
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
|
||||
const {
|
||||
list: [details]
|
||||
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
|
||||
clientId: uuid,
|
||||
listAll: fullPrivileges,
|
||||
omitSecret: false
|
||||
});
|
||||
|
||||
if (!details) {
|
||||
return error(404, 'Client not found');
|
||||
}
|
||||
|
||||
const body = await request.formData();
|
||||
const { email } = Changesets.take<InviteRequest>(['email'], body);
|
||||
|
||||
if (!email || !emailRegex.test(email)) {
|
||||
return fail(400, { errors: ['invalidEmail'] });
|
||||
}
|
||||
|
||||
const managers = await OAuth2Clients.getManagers(details as OAuth2Client);
|
||||
if (managers.some((entry) => entry.email.toLowerCase() === email.toLowerCase())) {
|
||||
return fail(400, { errors: ['emailExists'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
removeManager: async ({ locals, url, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
['admin:oauth2', 'self:oauth2']
|
||||
]);
|
||||
|
||||
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
|
||||
const {
|
||||
list: [details]
|
||||
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
|
||||
clientId: uuid,
|
||||
listAll: fullPrivileges,
|
||||
omitSecret: false
|
||||
});
|
||||
|
||||
if (!details) {
|
||||
return error(404, 'Client not found');
|
||||
}
|
||||
|
||||
const id = Number(url.searchParams.get('id'));
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { errors: ['invalidManagerId'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.removeManager(details as OAuth2Client, id);
|
||||
|
||||
return { errors: [] };
|
||||
}
|
||||
};
|
||||
@ -405,9 +472,11 @@ export const load = async ({ params: { uuid }, parent }) => {
|
||||
|
||||
const privileges = await Users.getAvailablePrivileges(details.id);
|
||||
const users = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client);
|
||||
const managers = await OAuth2Clients.getManagers(details as OAuth2Client);
|
||||
|
||||
return {
|
||||
users,
|
||||
managers,
|
||||
availableUrls: OAuth2Clients.availableUrlTypes,
|
||||
availablePrivileges: privileges,
|
||||
availableGrants: fullPrivileges
|
||||
|
@ -23,6 +23,7 @@
|
||||
let secret = false;
|
||||
let addingUrl = false;
|
||||
let addingPrivilege = false;
|
||||
let addingManager = false;
|
||||
|
||||
$: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri');
|
||||
$: availableUrls = data.availableUrls.filter((type) => {
|
||||
@ -290,6 +291,53 @@
|
||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||
</form>
|
||||
|
||||
<SplitView>
|
||||
{#if data.fullPrivileges || data.details.isOwner}
|
||||
<ColumnView>
|
||||
<h2>{$t('admin.oauth2.managers.title')}</h2>
|
||||
<p>{$t('admin.oauth2.managers.hint', { siteName: PUBLIC_SITE_NAME })}</p>
|
||||
|
||||
<div class="addremove">
|
||||
{#each data.managers as user}
|
||||
<div class="addremove-item">
|
||||
<b>{user.email}</b>
|
||||
<form action="?/removeManager&id={user.id}" method="POST">
|
||||
<Button type="submit" variant="link">{$t('common.remove')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if addingManager}
|
||||
<form action="?/invite" method="POST">
|
||||
<FormWrapper>
|
||||
<FormSection title={$t('admin.oauth2.managers.add')}>
|
||||
<FormControl>
|
||||
<label for="invite-email">{$t('admin.users.email')}</label>
|
||||
<input type="email" name="email" id="invite-email" />
|
||||
</FormControl>
|
||||
<ButtonRow>
|
||||
<Button type="submit" variant="primary"
|
||||
>{$t('admin.oauth2.managers.invite')}</Button
|
||||
>
|
||||
<Button variant="link" on:click={() => (addingManager = false)}
|
||||
>{$t('common.cancel')}</Button
|
||||
>
|
||||
</ButtonRow>
|
||||
</FormSection>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{:else}
|
||||
<div>
|
||||
<Button variant="link" on:click={() => (addingManager = true)}
|
||||
>+ {$t('admin.oauth2.managers.add')}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
{/if}
|
||||
|
||||
<ColumnView>
|
||||
<h2>{$t('admin.oauth2.apis.title')}</h2>
|
||||
<ul>
|
||||
<li>
|
||||
@ -303,7 +351,8 @@
|
||||
<li>
|
||||
{$t('admin.oauth2.apis.token')} -
|
||||
<code
|
||||
><a href={`/oauth2/token`} data-sveltekit-preload-data="off">{PUBLIC_URL}/oauth2/token</a
|
||||
><a href={`/oauth2/token`} data-sveltekit-preload-data="off"
|
||||
>{PUBLIC_URL}/oauth2/token</a
|
||||
></code
|
||||
>
|
||||
</li>
|
||||
@ -317,7 +366,9 @@
|
||||
</li>
|
||||
<li>
|
||||
{$t('admin.oauth2.apis.userinfo')} -
|
||||
<code><a href={`/api/user`} data-sveltekit-preload-data="off">{PUBLIC_URL}/api/user</a></code>
|
||||
<code
|
||||
><a href={`/api/user`} data-sveltekit-preload-data="off">{PUBLIC_URL}/api/user</a></code
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
{$t('admin.oauth2.apis.openid')} -
|
||||
@ -328,6 +379,9 @@
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</ColumnView>
|
||||
</SplitView>
|
||||
|
||||
<h2>{$t('admin.oauth2.authorizations')}</h2>
|
||||
<p>{$t('admin.oauth2.authorizationsHint')}</p>
|
||||
|
||||
|
39
src/routes/ssoadmin/oauth2/invite/+server.ts
Normal file
39
src/routes/ssoadmin/oauth2/invite/+server.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { ApiUtils } from '$lib/server/api-utils.js';
|
||||
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
|
||||
import { UserTokens, Users } from '$lib/server/users';
|
||||
|
||||
export const GET = async ({ locals, url }) => {
|
||||
const userInfo = locals.session.data?.user;
|
||||
const currentUser = await Users.getBySession(userInfo);
|
||||
if (!userInfo || !currentUser) {
|
||||
await locals.session.destroy();
|
||||
return ApiUtils.redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
|
||||
}
|
||||
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) {
|
||||
return ApiUtils.redirect('/');
|
||||
}
|
||||
|
||||
const fetch = await UserTokens.getByToken(token, 'invite');
|
||||
if (!fetch?.userId || !fetch.metadata?.startsWith('clientmanager=')) {
|
||||
return ApiUtils.redirect('/');
|
||||
}
|
||||
|
||||
const inviter = await Users.getById(fetch.userId);
|
||||
if (!inviter) {
|
||||
return ApiUtils.redirect('/');
|
||||
}
|
||||
|
||||
const [, clientId] = fetch.metadata.split('=');
|
||||
const client = await OAuth2Clients.fetchById(clientId);
|
||||
if (!client) {
|
||||
return ApiUtils.redirect('/');
|
||||
}
|
||||
|
||||
await OAuth2Clients.addManager(client, inviter, currentUser);
|
||||
await UserTokens.remove(fetch);
|
||||
|
||||
console.log('?');
|
||||
return ApiUtils.redirect(`/ssoadmin/oauth2/${client.client_id}`);
|
||||
};
|
Loading…
Reference in New Issue
Block a user