feat: oauth

This commit is contained in:
Nevo David 2026-03-03 00:46:46 +07:00
parent ff4ee6c5fe
commit 334dda7609
20 changed files with 1670 additions and 19 deletions

View file

@ -33,6 +33,9 @@ import { ThirdPartyController } from '@gitroom/backend/api/routes/third-party.co
import { MonitorController } from '@gitroom/backend/api/routes/monitor.controller';
import { NoAuthIntegrationsController } from '@gitroom/backend/api/routes/no.auth.integrations.controller';
import { EnterpriseController } from '@gitroom/backend/api/routes/enterprise.controller';
import { OAuthAppController } from '@gitroom/backend/api/routes/oauth-app.controller';
import { ApprovedAppsController } from '@gitroom/backend/api/routes/approved-apps.controller';
import { OAuthController } from '@gitroom/backend/api/routes/oauth.controller';
const authenticatedController = [
UsersController,
@ -49,6 +52,8 @@ const authenticatedController = [
AutopostController,
SetsController,
ThirdPartyController,
OAuthAppController,
ApprovedAppsController,
];
@Module({
imports: [UploadModule],
@ -60,6 +65,7 @@ const authenticatedController = [
MonitorController,
EnterpriseController,
NoAuthIntegrationsController,
OAuthController,
...authenticatedController,
],
providers: [

View file

@ -0,0 +1,24 @@
import { Controller, Delete, Get, Param } from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
@ApiTags('Approved Apps')
@Controller('/user/approved-apps')
export class ApprovedAppsController {
constructor(private _oauthService: OAuthService) {}
@Get('/')
async list(@GetUserFromRequest() user: User) {
return this._oauthService.getApprovedApps(user.id);
}
@Delete('/:id')
async revoke(
@GetUserFromRequest() user: User,
@Param('id') id: string
) {
return this._oauthService.revokeApp(user.id, id);
}
}

View file

@ -0,0 +1,54 @@
import { Body, Controller, Delete, Get, Post, Put } from '@nestjs/common';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { CreateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/create-oauth-app.dto';
import { UpdateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/update-oauth-app.dto';
@ApiTags('OAuth App')
@Controller('/user/oauth-app')
export class OAuthAppController {
constructor(private _oauthService: OAuthService) {}
@Get('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getApp(@GetOrgFromRequest() org: Organization) {
return this._oauthService.getApp(org.id);
}
@Post('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async createApp(
@GetOrgFromRequest() org: Organization,
@Body() body: CreateOAuthAppDto
) {
return this._oauthService.createApp(org.id, body);
}
@Put('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async updateApp(
@GetOrgFromRequest() org: Organization,
@Body() body: UpdateOAuthAppDto
) {
return this._oauthService.updateApp(org.id, body);
}
@Delete('/')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async deleteApp(@GetOrgFromRequest() org: Organization) {
return this._oauthService.deleteApp(org.id);
}
@Post('/rotate-secret')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async rotateSecret(@GetOrgFromRequest() org: Organization) {
return this._oauthService.rotateSecret(org.id);
}
}

View file

@ -0,0 +1,119 @@
import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { User } from '@prisma/client';
import { AuthorizeOAuthQueryDto, ApproveOAuthDto } from '@gitroom/nestjs-libraries/dtos/oauth/authorize-oauth.dto';
import { TokenExchangeDto } from '@gitroom/nestjs-libraries/dtos/oauth/token-exchange.dto';
@ApiTags('OAuth')
@Controller('/oauth')
export class OAuthController {
constructor(
private _oauthService: OAuthService,
private _organizationService: OrganizationService
) {}
@Get('/authorize')
async authorize(@Query() query: AuthorizeOAuthQueryDto) {
const app = await this._oauthService.validateAuthorizationRequest(
query.client_id
);
return {
app: {
name: app.name,
description: app.description,
picture: app.picture,
clientId: app.clientId,
redirectUrl: app.redirectUrl,
},
state: query.state,
};
}
@Post('/authorize')
async approveOrDeny(
@Body() body: ApproveOAuthDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
const auth = (req.headers as any).auth || req.cookies.auth;
if (!auth) {
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
}
let user: User;
try {
user = AuthService.verifyJWT(auth) as User;
if (!user) {
throw new Error();
}
} catch {
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
}
const app = await this._oauthService.validateAuthorizationRequest(
body.client_id
);
if (body.action === 'deny') {
const redirectUrl = new URL(app.redirectUrl);
redirectUrl.searchParams.set('error', 'access_denied');
if (body.state) {
redirectUrl.searchParams.set('state', body.state);
}
return { redirect: redirectUrl.toString() };
}
const orgs = await this._organizationService.getOrgsByUserId(user.id);
const org = orgs.find((o: any) => !o.users?.[0]?.disabled);
if (!org) {
throw new HttpException(
'No active organization found',
HttpStatus.BAD_REQUEST
);
}
const code = await this._oauthService.createAuthorizationCode(
app.id,
user.id,
org.id
);
const redirectUrl = new URL(app.redirectUrl);
redirectUrl.searchParams.set('code', code);
if (body.state) {
redirectUrl.searchParams.set('state', body.state);
}
return { redirect: redirectUrl.toString() };
}
@Post('/token')
async token(@Body() body: TokenExchangeDto) {
if (body.grant_type !== 'authorization_code') {
throw new HttpException(
{ error: 'unsupported_grant_type' },
HttpStatus.BAD_REQUEST
);
}
return this._oauthService.exchangeCodeForToken(
body.code,
body.client_id,
body.client_secret
);
}
}

View file

@ -1,11 +1,15 @@
import { HttpStatus, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
@Injectable()
export class PublicAuthMiddleware implements NestMiddleware {
constructor(private _organizationService: OrganizationService) {}
constructor(
private _organizationService: OrganizationService,
private _oauthService: OAuthService
) {}
async use(req: Request, res: Response, next: NextFunction) {
const auth = (req.headers.authorization ||
req.headers.Authorization) as string;
@ -14,21 +18,44 @@ export class PublicAuthMiddleware implements NestMiddleware {
return;
}
try {
const org = await this._organizationService.getOrgByApiKey(auth);
if (!org) {
res.status(HttpStatus.UNAUTHORIZED).json({ msg: 'Invalid API key' });
return;
}
if (auth.startsWith('pos_')) {
const authorization = await this._oauthService.getOrgByOAuthToken(auth);
if (!authorization) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'Invalid OAuth token' });
return;
}
if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'No subscription found' });
return;
}
const org = authorization.organization;
if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'No subscription found' });
return;
}
// @ts-ignore
req.org = { ...org, users: [{ users: { role: 'SUPERADMIN' } }] };
// @ts-ignore
req.org = { ...org, users: [{ users: { role: 'SUPERADMIN' } }] };
} else {
const org = await this._organizationService.getOrgByApiKey(auth);
if (!org) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'Invalid API key' });
return;
}
if (!!process.env.STRIPE_SECRET_KEY && !org.subscription) {
res
.status(HttpStatus.UNAUTHORIZED)
.json({ msg: 'No subscription found' });
return;
}
// @ts-ignore
req.org = { ...org, users: [{ users: { role: 'SUPERADMIN' } }] };
}
} catch (err) {
throw new HttpForbiddenException();
}

View file

@ -0,0 +1,18 @@
import { Metadata } from 'next';
import { ReactNode } from 'react';
export const metadata: Metadata = {
title: 'Authorize Application',
};
export default async function OAuthLayout({
children,
}: {
children: ReactNode;
}) {
return (
<div className="bg-[#0B0A0A] flex flex-1 min-h-screen w-screen">
{children}
</div>
);
}

View file

@ -0,0 +1,208 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Logo } from '@gitroom/frontend/components/new-layout/logo';
export default function OAuthAuthorizePage() {
const searchParams = useSearchParams();
const fetch = useFetch();
const [appInfo, setAppInfo] = useState<any>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const clientId = searchParams.get('client_id');
const responseType = searchParams.get('response_type');
const state = searchParams.get('state');
useEffect(() => {
if (!clientId || !responseType) {
setError('Missing required parameters (client_id, response_type)');
setLoading(false);
return;
}
if (responseType !== 'code') {
setError('Only response_type=code is supported');
setLoading(false);
return;
}
const params = new URLSearchParams({
client_id: clientId,
response_type: responseType,
...(state ? { state } : {}),
});
fetch(`/oauth/authorize?${params}`)
.then((r) => r.json())
.then((data) => {
if (data.statusCode && data.statusCode >= 400) {
setError(data.message || 'Invalid OAuth request');
} else {
setAppInfo(data);
}
setLoading(false);
})
.catch(() => {
setError('Failed to validate OAuth request');
setLoading(false);
});
}, [clientId, responseType, state]);
const handleAction = useCallback(
async (action: 'approve' | 'deny') => {
setSubmitting(true);
try {
const result = await (
await fetch('/oauth/authorize', {
method: 'POST',
body: JSON.stringify({
client_id: clientId,
state,
action,
}),
})
).json();
if (result.redirect) {
window.location.href = result.redirect;
}
} catch {
setError('Failed to process authorization');
setSubmitting(false);
}
},
[clientId, state]
);
if (loading) {
return (
<div className="flex flex-1 items-center justify-center text-white relative overflow-hidden">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-[20%] left-[10%] w-[300px] h-[300px] bg-[#612BD3] rounded-full blur-[120px]" />
<div className="absolute bottom-[20%] right-[10%] w-[250px] h-[250px] bg-[#FC69FF] rounded-full blur-[120px]" />
</div>
<div className="relative z-10 text-center">
<div className="flex justify-center mb-[24px]">
<Logo />
</div>
<div className="text-[16px] text-gray-400">
Please wait...
</div>
<div className="mt-[32px] flex justify-center">
<div className="w-[48px] h-[48px] border-[3px] border-[#612BD3] border-t-transparent rounded-full animate-spin" />
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-1 items-center justify-center text-white relative overflow-hidden">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-[20%] left-[10%] w-[300px] h-[300px] bg-[#612BD3] rounded-full blur-[120px]" />
<div className="absolute bottom-[20%] right-[10%] w-[250px] h-[250px] bg-[#FC69FF] rounded-full blur-[120px]" />
</div>
<div className="relative z-10 text-center">
<div className="flex justify-center mb-[24px]">
<Logo />
</div>
<div className="w-[80px] h-[80px] mx-auto mb-[24px] rounded-full bg-red-500/20 flex items-center justify-center">
<svg
className="w-[40px] h-[40px] text-red-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="text-[28px] font-semibold mb-[12px]">
Authorization Error
</div>
<div className="text-[16px] text-gray-400 max-w-[400px]">
{error}
</div>
</div>
</div>
);
}
if (!appInfo) {
return null;
}
return (
<div className="flex flex-1 items-center justify-center text-white relative overflow-hidden">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-[20%] left-[10%] w-[300px] h-[300px] bg-[#612BD3] rounded-full blur-[120px]" />
<div className="absolute bottom-[20%] right-[10%] w-[250px] h-[250px] bg-[#FC69FF] rounded-full blur-[120px]" />
</div>
<div className="relative z-10 w-full max-w-[500px] mx-auto px-[20px]">
<div className="flex justify-center mb-[32px]">
<Logo />
</div>
<div className="bg-[#1A1919] rounded-[16px] p-[32px] flex flex-col gap-[24px]">
<div className="flex flex-col items-center gap-[16px]">
{appInfo.app.picture?.path ? (
<img
src={appInfo.app.picture.path}
alt={appInfo.app.name}
className="w-[64px] h-[64px] rounded-full object-cover"
/>
) : (
<div className="w-[64px] h-[64px] rounded-full bg-[#2A2929] flex items-center justify-center text-[24px] text-gray-400">
{appInfo.app.name?.[0]?.toUpperCase() || '?'}
</div>
)}
<h2 className="text-[24px] font-semibold text-center">
{appInfo.app.name}
</h2>
{appInfo.app.description && (
<div className="text-gray-400 text-center text-[14px]">
{appInfo.app.description}
</div>
)}
</div>
<div className="border-t border-[#2A2929] pt-[16px]">
<div className="text-[14px] text-gray-400 mb-[12px]">
This application is requesting access to your Postiz account. It
will be able to:
</div>
<ul className="text-[14px] list-disc list-inside space-y-[4px]">
<li>Access your integrations and channels</li>
<li>Create and schedule posts on your behalf</li>
<li>Read your post analytics</li>
</ul>
</div>
<div className="flex gap-[12px]">
<button
onClick={() => handleAction('approve')}
disabled={submitting}
className="flex-1 bg-[#612BD3] hover:bg-[#7B3FF2] disabled:opacity-50 text-white rounded-[8px] py-[10px] px-[16px] text-[14px] font-semibold transition-colors"
>
Authorize
</button>
<button
onClick={() => handleAction('deny')}
disabled={submitting}
className="flex-1 bg-[#2A2929] hover:bg-[#3A3939] disabled:opacity-50 text-white rounded-[8px] py-[10px] px-[16px] text-[14px] font-semibold transition-colors"
>
Deny
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,124 @@
'use client';
import { FC, Fragment, useCallback } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { Button } from '@gitroom/react/form/button';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
const useApprovedApps = () => {
const fetch = useFetch();
const load = useCallback(async () => {
return (await fetch('/user/approved-apps')).json();
}, []);
return useSWR('approved-apps', load, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
});
};
export const ApprovedAppsComponent: FC = () => {
const fetch = useFetch();
const toaster = useToaster();
const t = useT();
const { data: apps, mutate } = useApprovedApps();
const revokeApp = useCallback(
(app: any) => async () => {
if (
await deleteDialog(
t(
'are_you_sure_revoke_access',
`Are you sure you want to revoke access for ${app.oauthApp?.name}?`,
{ name: app.oauthApp?.name }
)
)
) {
try {
await fetch(`/user/approved-apps/${app.id}`, {
method: 'DELETE',
});
toaster.show(
t('access_revoked', 'Access revoked successfully'),
'success'
);
mutate();
} catch {
toaster.show(t('failed_to_revoke', 'Failed to revoke access'), 'warning');
}
}
},
[]
);
if (apps === undefined) {
return null;
}
return (
<div className="flex flex-col gap-[20px]">
<div className="flex flex-col">
<h3 className="text-[20px]">
{t('approved_apps', 'Approved Apps')}
</h3>
<div className="text-customColor18 mt-[4px]">
{t(
'apps_you_have_authorized',
'Applications you have authorized to access your Postiz account.'
)}
</div>
</div>
<div className="bg-sixth border-fifth border rounded-[4px] p-[24px]">
{!apps?.length ? (
<div className="text-customColor18">
{t('no_approved_apps', 'No approved apps yet.')}
</div>
) : (
<div className="flex flex-col gap-[16px]">
{apps.map((app: any) => (
<div
key={app.id}
className="flex items-center justify-between p-[12px] border border-fifth rounded-[4px]"
>
<div className="flex items-center gap-[12px]">
{app.oauthApp?.picture?.path ? (
<img
src={app.oauthApp.picture.path}
alt={app.oauthApp.name}
className="w-[40px] h-[40px] rounded-full object-cover"
/>
) : (
<div className="w-[40px] h-[40px] rounded-full bg-fifth flex items-center justify-center text-customColor18">
{app.oauthApp?.name?.[0]?.toUpperCase() || '?'}
</div>
)}
<div>
<div className="text-[14px] font-bold">
{app.oauthApp?.name}
</div>
{app.oauthApp?.description && (
<div className="text-customColor18 text-[12px]">
{app.oauthApp.description}
</div>
)}
<div className="text-customColor18 text-[12px]">
{t('authorized_on', 'Authorized on')}{' '}
{new Date(app.createdAt).toLocaleDateString()}
</div>
</div>
</div>
<Button onClick={revokeApp(app)}>
{t('revoke', 'Revoke')}
</Button>
</div>
))}
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,488 @@
'use client';
import { FC, useCallback, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { Button } from '@gitroom/react/form/button';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useDecisionModal, useModals } from '@gitroom/frontend/components/layout/new-modal';
import { MediaBox } from '@gitroom/frontend/components/media/media.component';
import copy from 'copy-to-clipboard';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
const useOAuthApp = () => {
const fetch = useFetch();
const load = useCallback(async () => {
const res = await fetch('/user/oauth-app');
const text = await res.text();
if (!text || text === 'null' || text === 'false') {
return null;
}
return JSON.parse(text);
}, []);
return useSWR('oauth-app', load, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
});
};
export const DeveloperComponent: FC = () => {
const fetch = useFetch();
const toaster = useToaster();
const decision = useDecisionModal();
const modals = useModals();
const t = useT();
const { data: app, mutate } = useOAuthApp();
const [plaintextSecret, setPlaintextSecret] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [redirectUrl, setRedirectUrl] = useState('');
const [pictureId, setPictureId] = useState<string | undefined>(undefined);
const [picturePath, setPicturePath] = useState<string | undefined>(undefined);
const startEditing = useCallback(() => {
if (!app) return;
setName(app.name || '');
setDescription(app.description || '');
setRedirectUrl(app.redirectUrl || '');
setPictureId(app.pictureId || undefined);
setPicturePath(app.picture?.path || undefined);
setEditing(true);
}, [app]);
const changeMedia = useCallback((selected: { id: string; path: string }[]) => {
const media = Array.isArray(selected) ? selected[0] : selected;
if (media) {
setPictureId(media.id);
setPicturePath(media.path);
}
}, []);
const openMedia = useCallback(() => {
modals.openModal({
title: t('media_library', 'Media Library'),
askClose: false,
closeOnEscape: true,
fullScreen: true,
size: 'calc(100% - 80px)',
height: 'calc(100% - 80px)',
children: (close: () => void) => (
<MediaBox
setMedia={changeMedia}
closeModal={close}
/>
),
});
}, [modals, t, changeMedia]);
const createApp = useCallback(async () => {
if (!name || !redirectUrl) {
toaster.show('Name and Redirect URL are required', 'warning');
return;
}
try {
const result = await (
await fetch('/user/oauth-app', {
method: 'POST',
body: JSON.stringify({
name,
description,
redirectUrl,
pictureId,
}),
})
).json();
if (result.clientSecret) {
setPlaintextSecret(result.clientSecret);
toaster.show(
'App created! Copy your client secret now - it will only be shown once.',
'success'
);
}
setCreating(false);
mutate();
} catch {
toaster.show('Failed to create app', 'warning');
}
}, [name, description, redirectUrl, pictureId]);
const updateApp = useCallback(async () => {
try {
await fetch('/user/oauth-app', {
method: 'PUT',
body: JSON.stringify({
name,
description,
redirectUrl,
pictureId,
}),
});
toaster.show('App updated', 'success');
setEditing(false);
mutate();
} catch {
toaster.show('Failed to update app', 'warning');
}
}, [name, description, redirectUrl, pictureId]);
const rotateSecret = useCallback(async () => {
const approved = await decision.open({
title: 'Rotate Client Secret?',
description:
'This will generate a new client secret and invalidate the current one. Any integrations using the old secret will stop working.',
approveLabel: 'Rotate',
cancelLabel: 'Cancel',
});
if (!approved) return;
try {
const result = await (
await fetch('/user/oauth-app/rotate-secret', { method: 'POST' })
).json();
if (result.clientSecret) {
setPlaintextSecret(result.clientSecret);
toaster.show(
'Secret rotated! Copy your new client secret now.',
'success'
);
mutate();
}
} catch {
toaster.show('Failed to rotate secret', 'warning');
}
}, [decision]);
const deleteApp = useCallback(async () => {
const approved = await decision.open({
title: 'Delete OAuth App?',
description:
'This will delete the OAuth application and revoke all user authorizations. This action cannot be undone.',
approveLabel: 'Delete',
cancelLabel: 'Cancel',
});
if (!approved) return;
try {
await fetch('/user/oauth-app', { method: 'DELETE' });
toaster.show('OAuth app deleted', 'success');
setPlaintextSecret(null);
mutate();
} catch {
toaster.show('Failed to delete app', 'warning');
}
}, [decision]);
const copyToClipboard = useCallback(
(text: string, label: string) => {
copy(text);
toaster.show(`${label} copied to clipboard`, 'success');
},
[]
);
if (app === undefined) {
return null;
}
if (!app && !creating) {
return (
<div className="flex flex-col gap-[20px]">
<div className="flex flex-col">
<h3 className="text-[20px]">{t('developer', 'Developer')}</h3>
<div className="text-customColor18 mt-[4px]">
{t(
'create_an_oauth_application',
'Create an OAuth application to allow third-party integrations with Postiz on behalf of your users.'
)}
<br />
<a
className="underline hover:font-bold hover:underline"
href="https://docs.postiz.com/public-api/oauth"
target="_blank"
>
{t(
'read_the_oauth_documentation',
'Read the OAuth documentation.'
)}
</a>
</div>
<div className="my-[16px] mt-[16px] bg-sixth border-fifth border rounded-[4px] p-[24px]">
<Button onClick={() => setCreating(true)}>
{t('create_oauth_app', 'Create OAuth App')}
</Button>
</div>
</div>
</div>
);
}
if (creating && !app) {
return (
<div className="flex flex-col gap-[20px]">
<div className="flex flex-col">
<h3 className="text-[20px]">
{t('create_oauth_app', 'Create OAuth App')}
</h3>
<div className="text-customColor18 mt-[4px]">
{t(
'fill_in_the_details_for_your_oauth_application',
'Fill in the details for your OAuth application.'
)}
</div>
</div>
<div className="bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[16px]">
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">{t('app_name', 'App Name')} *</label>
<input
className="bg-input border border-fifth rounded-[4px] p-[8px] text-textColor outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Application"
maxLength={100}
/>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">
{t('description', 'Description')}
</label>
<textarea
className="bg-input border border-fifth rounded-[4px] p-[8px] text-textColor outline-none min-h-[80px]"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what your app does"
maxLength={500}
/>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">
{t('profile_picture', 'Profile Picture')}
</label>
<div className="flex items-center gap-[12px]">
{picturePath ? (
<img
src={picturePath}
alt="App picture"
className="w-[48px] h-[48px] rounded-full object-cover"
/>
) : (
<div className="w-[48px] h-[48px] rounded-full bg-fifth flex items-center justify-center text-customColor18">
?
</div>
)}
<Button onClick={openMedia}>
{t('choose_image', 'Choose Image')}
</Button>
</div>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">
{t('redirect_url', 'Redirect URL')} *
</label>
<input
className="bg-input border border-fifth rounded-[4px] p-[8px] text-textColor outline-none"
value={redirectUrl}
onChange={(e) => setRedirectUrl(e.target.value)}
placeholder="https://yourapp.com/callback"
/>
</div>
<div className="flex gap-[10px]">
<Button onClick={createApp}>
{t('create', 'Create')}
</Button>
<Button onClick={() => setCreating(false)}>
{t('cancel', 'Cancel')}
</Button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-[20px]">
<div className="flex flex-col">
<h3 className="text-[20px]">{t('developer', 'Developer')}</h3>
<div className="text-customColor18 mt-[4px]">
{t(
'manage_your_oauth_application',
'Manage your OAuth application for third-party integrations.'
)}
<br />
<a
className="underline hover:font-bold hover:underline"
href="https://docs.postiz.com/public-api/oauth"
target="_blank"
>
{t(
'read_the_oauth_documentation',
'Read the OAuth documentation.'
)}
</a>
</div>
</div>
{editing ? (
<div className="bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[16px]">
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">{t('app_name', 'App Name')} *</label>
<input
className="bg-input border border-fifth rounded-[4px] p-[8px] text-textColor outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Application"
maxLength={100}
/>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">
{t('description', 'Description')}
</label>
<textarea
className="bg-input border border-fifth rounded-[4px] p-[8px] text-textColor outline-none min-h-[80px]"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what your app does"
maxLength={500}
/>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">
{t('profile_picture', 'Profile Picture')}
</label>
<div className="flex items-center gap-[12px]">
{picturePath ? (
<img
src={picturePath}
alt="App picture"
className="w-[48px] h-[48px] rounded-full object-cover"
/>
) : (
<div className="w-[48px] h-[48px] rounded-full bg-fifth flex items-center justify-center text-customColor18">
?
</div>
)}
<Button onClick={openMedia}>
{t('choose_image', 'Choose Image')}
</Button>
</div>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px]">
{t('redirect_url', 'Redirect URL')} *
</label>
<input
className="bg-input border border-fifth rounded-[4px] p-[8px] text-textColor outline-none"
value={redirectUrl}
onChange={(e) => setRedirectUrl(e.target.value)}
placeholder="https://yourapp.com/callback"
/>
</div>
<div className="flex gap-[10px]">
<Button onClick={updateApp}>
{t('save', 'Save')}
</Button>
<Button onClick={() => setEditing(false)}>
{t('cancel', 'Cancel')}
</Button>
</div>
</div>
) : (
<div className="bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[16px]">
<div className="flex items-center gap-[12px]">
{app.picture?.path ? (
<img
src={app.picture.path}
alt={app.name}
className="w-[48px] h-[48px] rounded-full object-cover"
/>
) : (
<div className="w-[48px] h-[48px] rounded-full bg-fifth flex items-center justify-center text-customColor18">
{app.name?.[0]?.toUpperCase() || '?'}
</div>
)}
<div>
<div className="text-[16px] font-bold">{app.name}</div>
{app.description && (
<div className="text-customColor18 text-[14px]">
{app.description}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px] text-customColor18">
{t('redirect_url', 'Redirect URL')}
</label>
<div className="text-[14px]">{app.redirectUrl}</div>
</div>
<div>
<Button onClick={startEditing}>
{t('edit_app', 'Edit App')}
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-[12px]">
<h4 className="text-[16px]">{t('credentials', 'Credentials')}</h4>
<div className="bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[16px]">
<div className="flex flex-col gap-[4px]">
<label className="text-[14px] text-customColor18">
{t('client_id', 'Client ID')}
</label>
<div className="flex items-center gap-[12px]">
<code className="text-[14px] break-all">{app.clientId}</code>
<Button onClick={() => copyToClipboard(app.clientId, 'Client ID')}>
{t('copy', 'Copy')}
</Button>
</div>
</div>
<div className="flex flex-col gap-[4px]">
<label className="text-[14px] text-customColor18">
{t('client_secret', 'Client Secret')}
</label>
<div className="flex items-center gap-[12px]">
{plaintextSecret ? (
<code className="text-[14px] break-all">
{plaintextSecret}
</code>
) : (
<span className="text-customColor18 text-[14px]">
{t(
'secret_only_shown_on_creation',
'Secret is only shown on creation or rotation'
)}
</span>
)}
{plaintextSecret && (
<Button
onClick={() =>
copyToClipboard(plaintextSecret, 'Client Secret')
}
>
{t('copy', 'Copy')}
</Button>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-[10px]">
<Button onClick={rotateSecret}>
{t('rotate_secret', 'Rotate Secret')}
</Button>
<Button onClick={deleteApp}>
{t('delete_app', 'Delete App')}
</Button>
</div>
</div>
);
};

View file

@ -31,6 +31,7 @@ import { Autopost } from '@gitroom/frontend/components/autopost/autopost';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { SVGLine } from '@gitroom/frontend/components/launches/launches.component';
import { GlobalSettings } from '@gitroom/frontend/components/settings/global.settings';
import { ApprovedAppsComponent } from '@gitroom/frontend/components/approved-apps/approved-apps.component';
export const SettingsPopup: FC<{
getRef?: Ref<any>;
}> = (props) => {
@ -103,8 +104,9 @@ export const SettingsPopup: FC<{
arr.push({ tab: 'signatures', label: t('signatures', 'Signatures') });
}
if (user?.tier?.public_api && isGeneral && showLogout) {
arr.push({ tab: 'api', label: t('public_api', 'Public API') });
arr.push({ tab: 'api', label: t('developers', 'Developers') });
}
arr.push({ tab: 'approved_apps', label: t('approved_apps', 'Approved Apps') });
return arr;
}, [user, isGeneral, showLogout, t]);
@ -201,6 +203,12 @@ export const SettingsPopup: FC<{
<PublicComponent />
</div>
)}
{tab === 'approved_apps' && (
<div>
<ApprovedAppsComponent />
</div>
)}
</div>
</form>
</FormProvider>

View file

@ -10,7 +10,10 @@ import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useDecisionModal } from '@gitroom/frontend/components/layout/new-modal';
export const PublicComponent = () => {
import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component';
import clsx from 'clsx';
const PublicApiContent = () => {
const user = useUser();
const { backendUrl, frontEndUrl, mcpUrl } = useVariables();
const toaster = useToaster();
@ -165,3 +168,41 @@ export const PublicComponent = () => {
</div>
);
};
export const PublicComponent = () => {
const t = useT();
const [subTab, setSubTab] = useState<'api' | 'developer'>('api');
return (
<div className="flex flex-col gap-[20px]">
<div className="flex gap-[4px] border-b border-fifth">
<button
type="button"
className={clsx(
'px-[16px] py-[8px] text-[14px] rounded-t-[4px] transition-colors',
subTab === 'api'
? 'bg-sixth text-textColor border border-fifth border-b-0'
: 'text-customColor18 hover:text-textColor'
)}
onClick={() => setSubTab('api')}
>
{t('public_api', 'Public API')}
</button>
<button
type="button"
className={clsx(
'px-[16px] py-[8px] text-[14px] rounded-t-[4px] transition-colors',
subTab === 'developer'
? 'bg-sixth text-textColor border border-fifth border-b-0'
: 'text-customColor18 hover:text-textColor'
)}
onClick={() => setSubTab('developer')}
>
{t('apps', 'Apps')}
</button>
</div>
{subTab === 'api' && <PublicApiContent />}
{subTab === 'developer' && <DeveloperComponent />}
</div>
);
};

View file

@ -72,7 +72,7 @@ export async function middleware(request: NextRequest) {
const org = nextUrl.searchParams.get('org');
const url = new URL(nextUrl).search;
if (nextUrl.href.indexOf('/auth') === -1 && !authCookie) {
if (!nextUrl.pathname.startsWith('/auth') && !authCookie) {
const providers = ['google', 'settings'];
const findIndex = providers.find((p) => nextUrl.href.indexOf(p) > -1);
const additional = !findIndex
@ -90,10 +90,10 @@ export async function middleware(request: NextRequest) {
}
// If the url is /auth and the cookie exists, redirect to /
if (nextUrl.href.indexOf('/auth') > -1 && authCookie) {
if (nextUrl.pathname.startsWith('/auth') && authCookie) {
return NextResponse.redirect(new URL(`/${url}`, nextUrl.href));
}
if (nextUrl.href.indexOf('/auth') > -1 && !authCookie) {
if (nextUrl.pathname.startsWith('/auth') && !authCookie) {
if (org) {
const redirect = NextResponse.redirect(new URL(`/`, nextUrl.href));
redirect.cookies.set('org', org, {

View file

@ -36,6 +36,8 @@ import { ThirdPartyService } from '@gitroom/nestjs-libraries/database/prisma/thi
import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager';
import { FalService } from '@gitroom/nestjs-libraries/openai/fal.service';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.repository';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
@Global()
@Module({
@ -80,6 +82,8 @@ import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integration
SetsRepository,
ThirdPartyRepository,
ThirdPartyService,
OAuthRepository,
OAuthService,
VideoManager,
],
get exports() {

View file

@ -0,0 +1,225 @@
import { Injectable } from '@nestjs/common';
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
@Injectable()
export class OAuthRepository {
constructor(
private _oauthApp: PrismaRepository<'oAuthApp'>,
private _oauthAuth: PrismaRepository<'oAuthAuthorization'>
) {}
getAppByOrgId(orgId: string) {
return this._oauthApp.model.oAuthApp.findFirst({
where: {
organizationId: orgId,
deletedAt: null,
},
include: {
picture: true,
},
});
}
getAppByClientId(clientId: string) {
return this._oauthApp.model.oAuthApp.findFirst({
where: {
clientId,
deletedAt: null,
},
include: {
picture: true,
},
});
}
createApp(
orgId: string,
data: {
name: string;
description?: string;
pictureId?: string;
redirectUrl: string;
clientId: string;
clientSecret: string;
}
) {
return this._oauthApp.model.oAuthApp.create({
data: {
organizationId: orgId,
name: data.name,
description: data.description,
pictureId: data.pictureId,
redirectUrl: data.redirectUrl,
clientId: data.clientId,
clientSecret: data.clientSecret,
},
include: {
picture: true,
},
});
}
updateApp(
orgId: string,
data: {
name?: string;
description?: string;
pictureId?: string;
redirectUrl?: string;
}
) {
return this._oauthApp.model.oAuthApp.update({
where: {
organizationId: orgId,
deletedAt: null,
},
data,
include: {
picture: true,
},
});
}
deleteApp(orgId: string) {
return this._oauthApp.model.oAuthApp.update({
where: {
organizationId: orgId,
},
data: {
deletedAt: new Date(),
},
});
}
updateClientSecret(orgId: string, newSecret: string) {
return this._oauthApp.model.oAuthApp.update({
where: {
organizationId: orgId,
deletedAt: null,
},
data: {
clientSecret: newSecret,
},
});
}
createAuthorization(data: {
oauthAppId: string;
userId: string;
organizationId: string;
authorizationCode: string;
codeExpiresAt: Date;
}) {
return this._oauthAuth.model.oAuthAuthorization.upsert({
where: {
oauthAppId_userId_organizationId: {
oauthAppId: data.oauthAppId,
userId: data.userId,
organizationId: data.organizationId,
},
},
create: {
oauthAppId: data.oauthAppId,
userId: data.userId,
organizationId: data.organizationId,
authorizationCode: data.authorizationCode,
codeExpiresAt: data.codeExpiresAt,
},
update: {
authorizationCode: data.authorizationCode,
codeExpiresAt: data.codeExpiresAt,
accessToken: null,
revokedAt: null,
},
});
}
findByCode(encryptedCode: string) {
return this._oauthAuth.model.oAuthAuthorization.findFirst({
where: {
authorizationCode: encryptedCode,
revokedAt: null,
},
});
}
exchangeCodeForToken(id: string, encryptedToken: string) {
return this._oauthAuth.model.oAuthAuthorization.update({
where: { id },
data: {
accessToken: encryptedToken,
authorizationCode: null,
codeExpiresAt: null,
},
});
}
findByAccessToken(encryptedToken: string) {
return this._oauthAuth.model.oAuthAuthorization.findFirst({
where: {
accessToken: encryptedToken,
revokedAt: null,
},
include: {
organization: {
include: {
subscription: {
select: {
subscriptionTier: true,
totalChannels: true,
isLifetime: true,
},
},
},
},
user: {
select: { id: true },
},
},
});
}
getApprovedApps(userId: string) {
return this._oauthAuth.model.oAuthAuthorization.findMany({
where: {
userId,
revokedAt: null,
accessToken: { not: null },
},
include: {
oauthApp: {
include: {
picture: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
}
revokeAuthorization(userId: string, authId: string) {
return this._oauthAuth.model.oAuthAuthorization.update({
where: {
id: authId,
userId,
},
data: {
revokedAt: new Date(),
},
});
}
revokeAllForApp(oauthAppId: string) {
return this._oauthAuth.model.oAuthAuthorization.updateMany({
where: {
oauthAppId,
revokedAt: null,
},
data: {
revokedAt: new Date(),
},
});
}
}

View file

@ -0,0 +1,162 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.repository';
import { CreateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/create-oauth-app.dto';
import { UpdateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/update-oauth-app.dto';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
@Injectable()
export class OAuthService {
constructor(private _oauthRepository: OAuthRepository) {}
async getApp(orgId: string) {
const app = await this._oauthRepository.getAppByOrgId(orgId);
if (!app) return false;
const { clientSecret, ...rest } = app;
return rest;
}
async createApp(orgId: string, dto: CreateOAuthAppDto) {
const existing = await this._oauthRepository.getAppByOrgId(orgId);
if (existing) {
throw new HttpException(
'You can only have one OAuth application per organization',
HttpStatus.BAD_REQUEST
);
}
const clientId = 'pca_' + makeId(32);
const clientSecret = 'pcs_' + makeId(48);
const encryptedSecret = AuthService.fixedEncryption(clientSecret);
const app = await this._oauthRepository.createApp(orgId, {
name: dto.name,
description: dto.description,
pictureId: dto.pictureId,
redirectUrl: dto.redirectUrl,
clientId,
clientSecret: encryptedSecret,
});
return { ...app, clientSecret };
}
async updateApp(orgId: string, dto: UpdateOAuthAppDto) {
return this._oauthRepository.updateApp(orgId, {
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.pictureId !== undefined && { pictureId: dto.pictureId }),
...(dto.redirectUrl && { redirectUrl: dto.redirectUrl }),
});
}
async deleteApp(orgId: string) {
const app = await this._oauthRepository.getAppByOrgId(orgId);
if (!app) {
throw new HttpException('No OAuth app found', HttpStatus.NOT_FOUND);
}
await this._oauthRepository.revokeAllForApp(app.id);
await this._oauthRepository.deleteApp(orgId);
return { success: true };
}
async rotateSecret(orgId: string) {
const app = await this._oauthRepository.getAppByOrgId(orgId);
if (!app) {
throw new HttpException('No OAuth app found', HttpStatus.NOT_FOUND);
}
const newSecret = 'pcs_' + makeId(48);
const encrypted = AuthService.fixedEncryption(newSecret);
await this._oauthRepository.updateClientSecret(orgId, encrypted);
return { clientSecret: newSecret };
}
async validateAuthorizationRequest(clientId: string) {
const app = await this._oauthRepository.getAppByClientId(clientId);
if (!app) {
throw new HttpException('Invalid client_id', HttpStatus.BAD_REQUEST);
}
return app;
}
async createAuthorizationCode(
oauthAppId: string,
userId: string,
organizationId: string
) {
const code = makeId(32);
const encryptedCode = AuthService.fixedEncryption(code);
const codeExpiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this._oauthRepository.createAuthorization({
oauthAppId,
userId,
organizationId,
authorizationCode: encryptedCode,
codeExpiresAt,
});
return code;
}
async exchangeCodeForToken(
code: string,
clientId: string,
clientSecret: string
) {
const app = await this._oauthRepository.getAppByClientId(clientId);
if (!app) {
throw new HttpException(
{ error: 'invalid_client' },
HttpStatus.UNAUTHORIZED
);
}
if (app.clientSecret !== AuthService.fixedEncryption(clientSecret)) {
throw new HttpException(
{ error: 'invalid_client' },
HttpStatus.UNAUTHORIZED
);
}
const encryptedCode = AuthService.fixedEncryption(code);
const auth = await this._oauthRepository.findByCode(encryptedCode);
if (!auth || auth.oauthAppId !== app.id) {
throw new HttpException(
{ error: 'invalid_grant' },
HttpStatus.BAD_REQUEST
);
}
if (!auth.codeExpiresAt || new Date() > auth.codeExpiresAt) {
throw new HttpException(
{ error: 'invalid_grant', error_description: 'Code has expired' },
HttpStatus.BAD_REQUEST
);
}
const token = 'pos_' + makeId(40);
const encryptedToken = AuthService.fixedEncryption(token);
await this._oauthRepository.exchangeCodeForToken(auth.id, encryptedToken);
return {
access_token: token,
token_type: 'bearer',
};
}
async getOrgByOAuthToken(token: string) {
const encrypted = AuthService.fixedEncryption(token);
return this._oauthRepository.findByAccessToken(encrypted);
}
async getApprovedApps(userId: string) {
return this._oauthRepository.getApprovedApps(userId);
}
async revokeApp(userId: string, authId: string) {
await this._oauthRepository.revokeAuthorization(userId, authId);
return { success: true };
}
}

View file

@ -41,6 +41,8 @@ model Organization {
usedCodes UsedCodes[]
users UserOrganization[]
webhooks Webhooks[]
oauthApp OAuthApp?
oauthAuthorizations OAuthAuthorization[]
@@index([apiKey])
@@index([streakSince])
@ -110,6 +112,7 @@ model User {
sendSuccessEmails Boolean @default(true)
sendFailureEmails Boolean @default(true)
sendStreakEmails Boolean @default(true)
oauthAuthorizations OAuthAuthorization[]
@@unique([email, providerName])
@@index([lastReadNotifications])
@ -220,6 +223,7 @@ model Media {
organization Organization @relation(fields: [organizationId], references: [id])
agencies SocialMediaAgency[]
userPicture User[]
oauthApps OAuthApp[]
@@index([name])
@@index([organizationId])
@ -836,6 +840,51 @@ model mastra_workflow_snapshot {
@@unique([workflow_name, run_id], map: "public_mastra_workflow_snapshot_workflow_name_run_id_key")
}
model OAuthApp {
id String @id @default(uuid())
organizationId String @unique
name String
description String?
pictureId String?
redirectUrl String
clientId String @unique
clientSecret String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id])
picture Media? @relation(fields: [pictureId], references: [id])
authorizations OAuthAuthorization[]
@@index([clientId])
@@index([organizationId])
@@index([deletedAt])
}
model OAuthAuthorization {
id String @id @default(uuid())
oauthAppId String
userId String
organizationId String
accessToken String?
authorizationCode String?
codeExpiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
oauthApp OAuthApp @relation(fields: [oauthAppId], references: [id])
user User @relation(fields: [userId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([oauthAppId, userId, organizationId])
@@index([accessToken])
@@index([authorizationCode])
@@index([oauthAppId])
@@index([userId])
@@index([organizationId])
@@index([revokedAt])
}
enum OrderStatus {
PENDING
ACCEPTED

View file

@ -0,0 +1,31 @@
import { IsDefined, IsIn, IsOptional, IsString } from 'class-validator';
export class AuthorizeOAuthQueryDto {
@IsString()
@IsDefined()
client_id: string;
@IsString()
@IsDefined()
@IsIn(['code'])
response_type: string;
@IsString()
@IsOptional()
state?: string;
}
export class ApproveOAuthDto {
@IsString()
@IsDefined()
client_id: string;
@IsString()
@IsOptional()
state?: string;
@IsString()
@IsDefined()
@IsIn(['approve', 'deny'])
action: 'approve' | 'deny';
}

View file

@ -0,0 +1,22 @@
import { IsDefined, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class CreateOAuthAppDto {
@IsString()
@IsDefined()
@MaxLength(100)
name: string;
@IsString()
@IsOptional()
@MaxLength(500)
description?: string;
@IsString()
@IsOptional()
pictureId?: string;
@IsString()
@IsDefined()
@IsUrl()
redirectUrl: string;
}

View file

@ -0,0 +1,19 @@
import { IsDefined, IsString } from 'class-validator';
export class TokenExchangeDto {
@IsString()
@IsDefined()
grant_type: string;
@IsString()
@IsDefined()
code: string;
@IsString()
@IsDefined()
client_id: string;
@IsString()
@IsDefined()
client_secret: string;
}

View file

@ -0,0 +1,22 @@
import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class UpdateOAuthAppDto {
@IsString()
@IsOptional()
@MaxLength(100)
name?: string;
@IsString()
@IsOptional()
@MaxLength(500)
description?: string;
@IsString()
@IsOptional()
pictureId?: string;
@IsString()
@IsOptional()
@IsUrl()
redirectUrl?: string;
}