feat: oauth
This commit is contained in:
parent
ff4ee6c5fe
commit
334dda7609
20 changed files with 1670 additions and 19 deletions
|
|
@ -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: [
|
||||
|
|
|
|||
24
apps/backend/src/api/routes/approved-apps.controller.ts
Normal file
24
apps/backend/src/api/routes/approved-apps.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
54
apps/backend/src/api/routes/oauth-app.controller.ts
Normal file
54
apps/backend/src/api/routes/oauth-app.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
119
apps/backend/src/api/routes/oauth.controller.ts
Normal file
119
apps/backend/src/api/routes/oauth.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
18
apps/frontend/src/app/(app)/oauth/authorize/layout.tsx
Normal file
18
apps/frontend/src/app/(app)/oauth/authorize/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
apps/frontend/src/app/(app)/oauth/authorize/page.tsx
Normal file
208
apps/frontend/src/app/(app)/oauth/authorize/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
488
apps/frontend/src/components/developer/developer.component.tsx
Normal file
488
apps/frontend/src/components/developer/developer.component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue