fix(auth): improve oauth flow, fix invite flow (with google), add copy invite link
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { LogError } from '@/utils/errors';
|
||||||
import {
|
import {
|
||||||
Arctic,
|
Arctic,
|
||||||
type OAuth2Tokens,
|
type OAuth2Tokens,
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
google,
|
google,
|
||||||
setSessionTokenCookie,
|
setSessionTokenCookie,
|
||||||
} from '@openpanel/auth';
|
} from '@openpanel/auth';
|
||||||
import { type User, connectUserToOrganization, db } from '@openpanel/db';
|
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -37,66 +38,118 @@ async function getGithubEmail(githubAccessToken: string) {
|
|||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function githubCallback(
|
// New types and interfaces
|
||||||
req: FastifyRequest<{
|
type Provider = 'github' | 'google';
|
||||||
Querystring: {
|
interface OAuthUser {
|
||||||
code: string;
|
id: string;
|
||||||
state: string;
|
email: string;
|
||||||
};
|
firstName: string;
|
||||||
}>,
|
lastName?: string;
|
||||||
reply: FastifyReply,
|
}
|
||||||
) {
|
|
||||||
const schema = z.object({
|
// Shared utility functions
|
||||||
code: z.string(),
|
async function handleExistingUser({
|
||||||
state: z.string(),
|
account,
|
||||||
inviteId: z.string().nullish(),
|
oauthUser,
|
||||||
|
providerName,
|
||||||
|
reply,
|
||||||
|
}: {
|
||||||
|
account: Account;
|
||||||
|
oauthUser: OAuthUser;
|
||||||
|
providerName: Provider;
|
||||||
|
reply: FastifyReply;
|
||||||
|
}) {
|
||||||
|
const sessionToken = generateSessionToken();
|
||||||
|
const session = await createSession(sessionToken, account.userId);
|
||||||
|
|
||||||
|
await db.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: {
|
||||||
|
provider: providerName,
|
||||||
|
providerId: oauthUser.id,
|
||||||
|
email: oauthUser.email,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = schema.safeParse(req.query);
|
setSessionTokenCookie(
|
||||||
if (!query.success) {
|
(...args) => reply.setCookie(...args),
|
||||||
req.log.error('invalid callback query params', {
|
sessionToken,
|
||||||
error: query.error.message,
|
session.expiresAt,
|
||||||
query: req.query,
|
);
|
||||||
provider: 'github',
|
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
||||||
});
|
}
|
||||||
return reply.status(400).send(query.error.message);
|
|
||||||
|
async function handleNewUser({
|
||||||
|
oauthUser,
|
||||||
|
providerName,
|
||||||
|
inviteId,
|
||||||
|
reply,
|
||||||
|
}: {
|
||||||
|
oauthUser: OAuthUser;
|
||||||
|
providerName: Provider;
|
||||||
|
inviteId: string | undefined | null;
|
||||||
|
reply: FastifyReply;
|
||||||
|
}) {
|
||||||
|
const existingUser = await db.user.findFirst({
|
||||||
|
where: { email: oauthUser.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new LogError(
|
||||||
|
'Please sign in using your original authentication method',
|
||||||
|
{
|
||||||
|
existingUser,
|
||||||
|
oauthUser,
|
||||||
|
providerName,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, state, inviteId } = query.data;
|
const user = await db.user.create({
|
||||||
const storedState = req.cookies.github_oauth_state ?? null;
|
data: {
|
||||||
|
email: oauthUser.email,
|
||||||
|
firstName: oauthUser.firstName,
|
||||||
|
lastName: oauthUser.lastName,
|
||||||
|
accounts: {
|
||||||
|
create: {
|
||||||
|
provider: providerName,
|
||||||
|
providerId: oauthUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (code === null || state === null || storedState === null) {
|
if (inviteId) {
|
||||||
req.log.error('missing oauth parameters', {
|
try {
|
||||||
code: code === null,
|
await connectUserToOrganization({ user, inviteId });
|
||||||
state: state === null,
|
} catch (error) {
|
||||||
storedState: storedState === null,
|
reply.log.error('error connecting user to organization', {
|
||||||
provider: 'github',
|
error,
|
||||||
});
|
inviteId,
|
||||||
return reply.status(400).send('Please restart the process.');
|
user,
|
||||||
}
|
});
|
||||||
if (state !== storedState) {
|
}
|
||||||
req.log.error('oauth state mismatch', {
|
|
||||||
state,
|
|
||||||
storedState,
|
|
||||||
provider: 'github',
|
|
||||||
});
|
|
||||||
return reply.status(400).send('Please restart the process.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let tokens: OAuth2Tokens;
|
const sessionToken = generateSessionToken();
|
||||||
try {
|
const session = await createSession(sessionToken, user.id);
|
||||||
tokens = await github.validateAuthorizationCode(code);
|
setSessionTokenCookie(
|
||||||
} catch (error) {
|
(...args) => reply.setCookie(...args),
|
||||||
req.log.error('github authorization failed', {
|
sessionToken,
|
||||||
error,
|
session.expiresAt,
|
||||||
provider: 'github',
|
);
|
||||||
});
|
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
||||||
return reply.status(400).send('Please restart the process.');
|
}
|
||||||
|
|
||||||
|
// Provider-specific user fetching
|
||||||
|
async function fetchGithubUser(accessToken: string): Promise<OAuthUser> {
|
||||||
|
const email = await getGithubEmail(accessToken);
|
||||||
|
if (!email) {
|
||||||
|
throw new LogError('GitHub email not found or not verified');
|
||||||
}
|
}
|
||||||
const githubAccessToken = tokens.accessToken();
|
|
||||||
|
|
||||||
const userRequest = new Request('https://api.github.com/user');
|
const userRequest = new Request('https://api.github.com/user');
|
||||||
userRequest.headers.set('Authorization', `Bearer ${githubAccessToken}`);
|
userRequest.headers.set('Authorization', `Bearer ${accessToken}`);
|
||||||
const userResponse = await fetch(userRequest);
|
const userResponse = await fetch(userRequest);
|
||||||
|
|
||||||
const userSchema = z.object({
|
const userSchema = z.object({
|
||||||
@@ -111,338 +164,197 @@ export async function githubCallback(
|
|||||||
|
|
||||||
const userResult = userSchema.safeParse(userJson);
|
const userResult = userSchema.safeParse(userJson);
|
||||||
if (!userResult.success) {
|
if (!userResult.success) {
|
||||||
req.log.error('user schema error', {
|
throw new LogError('Error fetching Github user', {
|
||||||
error: userResult.error.message,
|
error: userResult.error,
|
||||||
userJson,
|
githubUser: userJson,
|
||||||
provider: 'github',
|
|
||||||
});
|
});
|
||||||
return reply.status(400).send(userResult.error.message);
|
|
||||||
}
|
|
||||||
const githubUserId = userResult.data.id;
|
|
||||||
const email = await getGithubEmail(githubAccessToken);
|
|
||||||
|
|
||||||
const existingUser = await db.account.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
provider: 'github',
|
|
||||||
providerId: String(githubUserId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: 'github',
|
|
||||||
providerId: null,
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: 'oauth',
|
|
||||||
user: {
|
|
||||||
email: email ?? '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser !== null) {
|
|
||||||
const sessionToken = generateSessionToken();
|
|
||||||
const session = await createSession(sessionToken, existingUser.userId);
|
|
||||||
|
|
||||||
if (existingUser.provider === 'oauth') {
|
|
||||||
await db.account.update({
|
|
||||||
where: {
|
|
||||||
id: existingUser.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
provider: 'github',
|
|
||||||
providerId: String(githubUserId),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (existingUser.provider !== 'github') {
|
|
||||||
await db.account.create({
|
|
||||||
data: {
|
|
||||||
provider: 'github',
|
|
||||||
providerId: String(githubUserId),
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: existingUser.userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (existingUser.provider === 'github') {
|
|
||||||
await db.account.update({
|
|
||||||
where: {
|
|
||||||
id: existingUser.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
providerId: String(githubUserId),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessionTokenCookie(
|
|
||||||
(...args) => reply.setCookie(...args),
|
|
||||||
sessionToken,
|
|
||||||
session.expiresAt,
|
|
||||||
);
|
|
||||||
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email === null) {
|
return {
|
||||||
req.log.error('github email not found or not verified', {
|
id: String(userResult.data.id),
|
||||||
githubUserId,
|
email,
|
||||||
provider: 'github',
|
firstName: userResult.data.name || userResult.data.login || '',
|
||||||
});
|
};
|
||||||
return reply.status(400).send('Please verify your GitHub email address.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// (githubUserId, email, username);
|
|
||||||
const user = await await db.user.create({
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
firstName: userResult.data.name || userResult.data.login || '',
|
|
||||||
accounts: {
|
|
||||||
create: {
|
|
||||||
provider: 'github',
|
|
||||||
providerId: String(githubUserId),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (inviteId) {
|
|
||||||
try {
|
|
||||||
await connectUserToOrganization({ user, inviteId });
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Unknown error connecting user to projects',
|
|
||||||
{
|
|
||||||
inviteId,
|
|
||||||
email: user.email,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = generateSessionToken();
|
|
||||||
const session = await createSession(sessionToken, user.id);
|
|
||||||
setSessionTokenCookie(
|
|
||||||
(...args) => reply.setCookie(...args),
|
|
||||||
sessionToken,
|
|
||||||
session.expiresAt,
|
|
||||||
);
|
|
||||||
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function googleCallback(
|
async function fetchGoogleUser(tokens: OAuth2Tokens): Promise<OAuthUser> {
|
||||||
req: FastifyRequest<{
|
const claims = Arctic.decodeIdToken(tokens.idToken());
|
||||||
Querystring: {
|
|
||||||
code: string;
|
const claimsSchema = z.object({
|
||||||
state: string;
|
sub: z.string(),
|
||||||
};
|
email: z.string(),
|
||||||
}>,
|
email_verified: z.boolean(),
|
||||||
reply: FastifyReply,
|
given_name: z.string().optional(),
|
||||||
) {
|
family_name: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const claimsResult = claimsSchema.safeParse(claims);
|
||||||
|
if (!claimsResult.success) {
|
||||||
|
throw new LogError('Error fetching Google user', {
|
||||||
|
error: claimsResult.error,
|
||||||
|
claims,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!claimsResult.data.email_verified) {
|
||||||
|
throw new LogError('Email not verified with Google');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: claimsResult.data.sub,
|
||||||
|
email: claimsResult.data.email,
|
||||||
|
firstName: claimsResult.data.given_name || '',
|
||||||
|
lastName: claimsResult.data.family_name || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidatedOAuthQuery {
|
||||||
|
code: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateOAuthCallback(
|
||||||
|
req: FastifyRequest,
|
||||||
|
provider: Provider,
|
||||||
|
): Promise<ValidatedOAuthQuery> {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
state: z.string(),
|
state: z.string(),
|
||||||
inviteId: z.string().nullish(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = schema.safeParse(req.query);
|
const query = schema.safeParse(req.query);
|
||||||
if (!query.success) {
|
if (!query.success) {
|
||||||
req.log.error('invalid callback query params', {
|
throw new LogError('Invalid callback query params', {
|
||||||
error: query.error.message,
|
error: query.error,
|
||||||
query: req.query,
|
query: req.query,
|
||||||
provider: 'google',
|
provider,
|
||||||
});
|
});
|
||||||
return reply.status(400).send(query.error.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, state, inviteId } = query.data;
|
const { code, state } = query.data;
|
||||||
const storedState = req.cookies.google_oauth_state ?? null;
|
const storedState = req.cookies[`${provider}_oauth_state`] ?? null;
|
||||||
const codeVerifier = req.cookies.google_code_verifier ?? null;
|
const codeVerifier =
|
||||||
|
provider === 'google' ? (req.cookies.google_code_verifier ?? null) : null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
code === null ||
|
code === null ||
|
||||||
state === null ||
|
state === null ||
|
||||||
storedState === null ||
|
storedState === null ||
|
||||||
codeVerifier === null
|
(provider === 'google' && codeVerifier === null)
|
||||||
) {
|
) {
|
||||||
req.log.error('missing oauth parameters', {
|
throw new LogError('Missing oauth parameters', {
|
||||||
code: code === null,
|
code: code === null,
|
||||||
state: state === null,
|
state: state === null,
|
||||||
storedState: storedState === null,
|
storedState: storedState === null,
|
||||||
codeVerifier: codeVerifier === null,
|
codeVerifier: provider === 'google' ? codeVerifier === null : undefined,
|
||||||
provider: 'google',
|
provider,
|
||||||
});
|
});
|
||||||
return reply.status(400).send('Please restart the process.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state !== storedState) {
|
if (state !== storedState) {
|
||||||
req.log.error('oauth state mismatch', {
|
throw new LogError('OAuth state mismatch', {
|
||||||
state,
|
state,
|
||||||
storedState,
|
storedState,
|
||||||
provider: 'google',
|
provider,
|
||||||
});
|
});
|
||||||
return reply.status(400).send('Please restart the process.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let tokens: OAuth2Tokens;
|
return { code, state };
|
||||||
try {
|
}
|
||||||
tokens = await google.validateAuthorizationCode(code, codeVerifier);
|
|
||||||
} catch (error) {
|
// Main callback handlers
|
||||||
req.log.error('google authorization failed', {
|
export async function githubCallback(req: FastifyRequest, reply: FastifyReply) {
|
||||||
error,
|
try {
|
||||||
provider: 'google',
|
const { code } = await validateOAuthCallback(req, 'github');
|
||||||
});
|
const inviteId = req.cookies.inviteId;
|
||||||
return reply.status(400).send('Please restart the process.');
|
const tokens = await github.validateAuthorizationCode(code);
|
||||||
}
|
const githubUser = await fetchGithubUser(tokens.accessToken());
|
||||||
|
const account = await db.account.findFirst({
|
||||||
const claims = Arctic.decodeIdToken(tokens.idToken());
|
where: {
|
||||||
|
OR: [
|
||||||
const claimsParser = z.object({
|
// To keep
|
||||||
sub: z.string(),
|
{ provider: 'github', providerId: githubUser.id },
|
||||||
given_name: z
|
// During migration
|
||||||
.string()
|
{ provider: 'github', providerId: null, email: githubUser.email },
|
||||||
.nullish()
|
{ provider: 'oauth', user: { email: githubUser.email } },
|
||||||
.transform((val) => val || ''),
|
],
|
||||||
family_name: z
|
},
|
||||||
.string()
|
});
|
||||||
.nullish()
|
|
||||||
.transform((val) => val || ''),
|
reply.clearCookie('github_oauth_state');
|
||||||
picture: z
|
|
||||||
.string()
|
if (account) {
|
||||||
.nullish()
|
return await handleExistingUser({
|
||||||
.transform((val) => val || ''),
|
account,
|
||||||
email: z.string(),
|
oauthUser: githubUser,
|
||||||
});
|
providerName: 'github',
|
||||||
|
reply,
|
||||||
const claimsResult = claimsParser.safeParse(claims);
|
});
|
||||||
if (!claimsResult.success) {
|
}
|
||||||
req.log.error('invalid claims format', {
|
|
||||||
error: claimsResult.error.message,
|
return await handleNewUser({
|
||||||
claims,
|
oauthUser: githubUser,
|
||||||
provider: 'google',
|
providerName: 'github',
|
||||||
});
|
inviteId,
|
||||||
return reply.status(400).send(claimsResult.error.message);
|
reply,
|
||||||
}
|
});
|
||||||
|
} catch (error) {
|
||||||
const { sub: googleId, given_name, family_name, email } = claimsResult.data;
|
req.log.error(error);
|
||||||
|
return redirectWithError(reply, error);
|
||||||
const existingAccount = await db.account.findFirst({
|
}
|
||||||
where: {
|
}
|
||||||
OR: [
|
|
||||||
{
|
export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
|
||||||
provider: 'google',
|
try {
|
||||||
providerId: googleId,
|
const { code } = await validateOAuthCallback(req, 'google');
|
||||||
},
|
const inviteId = req.cookies.inviteId;
|
||||||
{
|
const codeVerifier = req.cookies.google_code_verifier!;
|
||||||
provider: 'google',
|
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
|
||||||
providerId: null,
|
const googleUser = await fetchGoogleUser(tokens);
|
||||||
email,
|
const existingUser = await db.account.findFirst({
|
||||||
},
|
where: {
|
||||||
{
|
OR: [
|
||||||
provider: 'oauth',
|
// To keep
|
||||||
user: {
|
{ provider: 'google', providerId: googleUser.id },
|
||||||
email,
|
// During migration
|
||||||
},
|
{ provider: 'google', providerId: null, email: googleUser.email },
|
||||||
},
|
{ provider: 'oauth', user: { email: googleUser.email } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingAccount !== null) {
|
reply.clearCookie('google_code_verifier');
|
||||||
const sessionToken = generateSessionToken();
|
reply.clearCookie('google_oauth_state');
|
||||||
const session = await createSession(sessionToken, existingAccount.userId);
|
|
||||||
|
if (existingUser) {
|
||||||
if (existingAccount.provider === 'oauth') {
|
return await handleExistingUser({
|
||||||
await db.account.update({
|
account: existingUser,
|
||||||
where: {
|
oauthUser: googleUser,
|
||||||
id: existingAccount.id,
|
providerName: 'google',
|
||||||
},
|
reply,
|
||||||
data: {
|
});
|
||||||
provider: 'google',
|
}
|
||||||
providerId: googleId,
|
|
||||||
},
|
return await handleNewUser({
|
||||||
});
|
oauthUser: googleUser,
|
||||||
} else if (existingAccount.provider !== 'google') {
|
providerName: 'google',
|
||||||
await db.account.create({
|
inviteId,
|
||||||
data: {
|
reply,
|
||||||
provider: 'google',
|
});
|
||||||
providerId: googleId,
|
} catch (error) {
|
||||||
user: {
|
req.log.error(error);
|
||||||
connect: {
|
return redirectWithError(reply, error);
|
||||||
id: existingAccount.userId,
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
},
|
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||||
});
|
const url = new URL(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
||||||
} else if (existingAccount.provider === 'google') {
|
url.pathname = '/login';
|
||||||
await db.account.update({
|
if (error instanceof LogError) {
|
||||||
where: {
|
url.searchParams.set('error', error.message);
|
||||||
id: existingAccount.id,
|
} else {
|
||||||
},
|
url.searchParams.set('error', 'An error occurred');
|
||||||
data: {
|
}
|
||||||
providerId: googleId,
|
url.searchParams.set('correlationId', reply.request.id);
|
||||||
},
|
return reply.redirect(url.toString());
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessionTokenCookie(
|
|
||||||
(...args) => reply.setCookie(...args),
|
|
||||||
sessionToken,
|
|
||||||
session.expiresAt,
|
|
||||||
);
|
|
||||||
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await db.user.upsert({
|
|
||||||
where: {
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
firstName: given_name,
|
|
||||||
lastName: family_name,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
email,
|
|
||||||
firstName: given_name,
|
|
||||||
lastName: family_name,
|
|
||||||
accounts: {
|
|
||||||
create: {
|
|
||||||
provider: 'google',
|
|
||||||
providerId: googleId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (inviteId) {
|
|
||||||
try {
|
|
||||||
await connectUserToOrganization({ user, inviteId });
|
|
||||||
} catch (error) {
|
|
||||||
req.log.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Unknown error connecting user to projects',
|
|
||||||
{
|
|
||||||
inviteId,
|
|
||||||
email: user.email,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = generateSessionToken();
|
|
||||||
const session = await createSession(sessionToken, user.id);
|
|
||||||
setSessionTokenCookie(
|
|
||||||
(...args) => reply.setCookie(...args),
|
|
||||||
sessionToken,
|
|
||||||
session.expiresAt,
|
|
||||||
);
|
|
||||||
return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!);
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
apps/api/src/utils/errors.ts
Normal file
15
apps/api/src/utils/errors.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export class LogError extends Error {
|
||||||
|
public readonly payload?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
options?: ErrorOptions,
|
||||||
|
) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'LogError';
|
||||||
|
this.payload = payload;
|
||||||
|
|
||||||
|
Object.setPrototypeOf(this, new.target.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,20 @@ import { Or } from '@/components/auth/or';
|
|||||||
import { SignInEmailForm } from '@/components/auth/sign-in-email-form';
|
import { SignInEmailForm } from '@/components/auth/sign-in-email-form';
|
||||||
import { SignInGithub } from '@/components/auth/sign-in-github';
|
import { SignInGithub } from '@/components/auth/sign-in-github';
|
||||||
import { SignInGoogle } from '@/components/auth/sign-in-google';
|
import { SignInGoogle } from '@/components/auth/sign-in-google';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { LinkButton } from '@/components/ui/button';
|
import { LinkButton } from '@/components/ui/button';
|
||||||
import { auth } from '@openpanel/auth/nextjs';
|
import { auth } from '@openpanel/auth/nextjs';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { error?: string; correlationId?: string };
|
||||||
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
const error = searchParams.error;
|
||||||
|
const correlationId = searchParams.correlationId;
|
||||||
|
|
||||||
if (session.userId) {
|
if (session.userId) {
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
@@ -16,6 +24,29 @@ export default async function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full center-center w-full">
|
<div className="flex h-full center-center w-full">
|
||||||
<div className="col gap-8 max-w-md w-full">
|
<div className="col gap-8 max-w-md w-full">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="text-left bg-background">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{error}</p>
|
||||||
|
{correlationId && (
|
||||||
|
<>
|
||||||
|
<p>Correlation ID: {correlationId}</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Contact us if you have any issues.{' '}
|
||||||
|
<a
|
||||||
|
className="underline font-medium"
|
||||||
|
href={`mailto:hello@openpanel.dev?subject=Login%20Issue%20-%20Correlation%20ID%3A%20${correlationId}`}
|
||||||
|
>
|
||||||
|
hello[at]openpanel.dev
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<div className="col md:row gap-4">
|
<div className="col md:row gap-4">
|
||||||
<SignInGithub type="sign-in" />
|
<SignInGithub type="sign-in" />
|
||||||
<SignInGoogle type="sign-in" />
|
<SignInGoogle type="sign-in" />
|
||||||
@@ -27,18 +58,6 @@ export default async function Page() {
|
|||||||
<LinkButton variant={'outline'} size="lg" href="/onboarding">
|
<LinkButton variant={'outline'} size="lg" href="/onboarding">
|
||||||
No account? Sign up today
|
No account? Sign up today
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
<p className="text-sm text-muted-foreground leading-tight">
|
|
||||||
Having issues logging in?
|
|
||||||
<br />
|
|
||||||
Contact us at{' '}
|
|
||||||
<a
|
|
||||||
href="mailto:hello@openpanel.dev"
|
|
||||||
className="text-primary underline"
|
|
||||||
>
|
|
||||||
hello[at]openpanel.dev
|
|
||||||
</a>
|
|
||||||
. We're not using Clerk (auth provider) anymore.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import { pathOr } from 'ramda';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { ACTIONS } from '@/components/data-table';
|
import { ACTIONS } from '@/components/data-table';
|
||||||
|
import { clipboard } from '@/utils/clipboard';
|
||||||
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
|
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
|
||||||
|
|
||||||
export function useColumns(
|
export function useColumns(
|
||||||
projects: IServiceProject[],
|
projects: IServiceProject[],
|
||||||
): ColumnDef<IServiceInvite>[] {
|
): ColumnDef<IServiceInvite>[] {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'email',
|
accessorKey: 'email',
|
||||||
header: 'Mail',
|
header: 'Mail',
|
||||||
@@ -99,6 +103,15 @@ function ActionCell({ row }: { row: Row<IServiceInvite> }) {
|
|||||||
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
|
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
clipboard(
|
||||||
|
`${window.location.origin}/onboarding?inviteId=${row.original.id}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy invite link
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(({ input, ctx }) => {
|
.mutation(({ input, ctx }) => {
|
||||||
const { provider } = input;
|
const { provider } = input;
|
||||||
|
|
||||||
|
if (input.inviteId) {
|
||||||
|
ctx.setCookie('inviteId', input.inviteId, {
|
||||||
|
maxAge: 60 * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === 'github') {
|
if (provider === 'github') {
|
||||||
const state = Arctic.generateState();
|
const state = Arctic.generateState();
|
||||||
const url = github.createAuthorizationURL(state, [
|
const url = github.createAuthorizationURL(state, [
|
||||||
@@ -49,17 +55,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
'user:read',
|
'user:read',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// if we have an inviteId we want to add it to the redirect url
|
|
||||||
// so we have this information in the callback url later
|
|
||||||
if (input.inviteId) {
|
|
||||||
const redirectUri = url.searchParams.get('redirect_uri');
|
|
||||||
if (redirectUri) {
|
|
||||||
const redirectUrl = new URL(redirectUri);
|
|
||||||
redirectUrl.searchParams.set('inviteId', input.inviteId);
|
|
||||||
url.searchParams.set('redirect_uri', redirectUrl.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.setCookie('github_oauth_state', state, {
|
ctx.setCookie('github_oauth_state', state, {
|
||||||
maxAge: 60 * 10,
|
maxAge: 60 * 10,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user