From c6a8f54409474c0961afdb730eafb4f7f693b530 Mon Sep 17 00:00:00 2001 From: stagas Date: Thu, 3 Oct 2024 23:59:59 +0300 Subject: [PATCH] feat: oauth with github (#7) * feat: oauth with github * fix: import paths * fix: handle secrets * fix: fallback to /index.html * fix: origin * fix: strip last / * fix: complete oauth after nick register * chore: relax codecov * fix: relax codecov more * fix: relax codecov even more * fix: add patch thresholds in codecov --- .github/workflows/deploy.yml | 6 +- .../{pr-cleanup.yml => preview-cleanup.yml} | 2 +- .github/workflows/{pr.yml => preview.yml} | 6 +- README.md | 4 +- api/actions/admin.ts | 6 +- api/actions/login-register.ts | 104 ++++++++++---- api/actions/oauth.ts | 31 +++++ api/core/fetch-json.ts | 7 + api/core/middleware.ts | 13 +- api/core/router.ts | 10 ++ api/core/server.ts | 5 + api/env.ts | 10 ++ api/models.ts | 6 +- api/routes/oauth/common.ts | 26 ++++ api/routes/oauth/github.ts | 131 ++++++++++++++++++ api/routes/rpc.ts | 21 ++- api/schemas/user.ts | 27 ++-- codecov.yml | 9 ++ kysely.config.ts | 1 - migrations/1727778500969_user-table.ts | 5 +- src/pages/App.tsx | 18 +++ src/pages/Home.tsx | 24 +++- src/pages/OAuthRegister.tsx | 42 ++++++ src/rpc/oauth.ts | 5 + src/test/e2e.test.tsx | 3 + 25 files changed, 445 insertions(+), 77 deletions(-) rename .github/workflows/{pr-cleanup.yml => preview-cleanup.yml} (97%) rename .github/workflows/{pr.yml => preview.yml} (92%) create mode 100644 api/actions/oauth.ts create mode 100644 api/core/fetch-json.ts create mode 100644 api/routes/oauth/common.ts create mode 100644 api/routes/oauth/github.ts create mode 100644 codecov.yml create mode 100644 src/pages/OAuthRegister.tsx create mode 100644 src/rpc/oauth.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 88c58f8..99af8b8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,7 +6,7 @@ on: jobs: deploy: - name: Deploy + name: Production runs-on: ubuntu-latest environment: Production @@ -40,7 +40,9 @@ jobs: - name: Generate .env file uses: SpicyPizza/create-envfile@v2.0 with: - envkey_DATABASE_URL: ${{ secrets.DATABASE_URL }} + envkey_DATABASE_URL: ${{ env.DATABASE_URL }} + envkey_OAUTH_GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }} + envkey_OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }} - name: Run migrations run: bun run kysely -- migrate:latest diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/preview-cleanup.yml similarity index 97% rename from .github/workflows/pr-cleanup.yml rename to .github/workflows/preview-cleanup.yml index 34724fe..54a35bc 100644 --- a/.github/workflows/pr-cleanup.yml +++ b/.github/workflows/preview-cleanup.yml @@ -7,7 +7,7 @@ on: jobs: cleanup: - name: Cleanup + name: PR runs-on: ubuntu-latest environment: Preview diff --git a/.github/workflows/pr.yml b/.github/workflows/preview.yml similarity index 92% rename from .github/workflows/pr.yml rename to .github/workflows/preview.yml index 3e522cc..156c422 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/preview.yml @@ -1,4 +1,4 @@ -name: PR +name: Preview on: pull_request: @@ -8,7 +8,7 @@ on: - synchronize jobs: - pr: + preview: name: PR runs-on: ubuntu-latest environment: Preview @@ -61,6 +61,8 @@ jobs: uses: SpicyPizza/create-envfile@v2.0 with: envkey_DATABASE_URL: ${{ env.DATABASE_URL }} + envkey_OAUTH_GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }} + envkey_OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }} - name: Run migrations run: bun run kysely -- migrate:latest diff --git a/README.md b/README.md index b549c26..3738c61 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ - [x] Emails (Resend) - [x] Login/Register - [x] Nick, email, password - - [ ] OAuth providers - - [ ] GitHub + - [x] OAuth providers + - [x] GitHub - [ ] Google - [ ] Facebook - [ ] X/Twitter diff --git a/api/actions/admin.ts b/api/actions/admin.ts index c44ac24..16ea87c 100644 --- a/api/actions/admin.ts +++ b/api/actions/admin.ts @@ -1,8 +1,8 @@ import { kv } from '../core/app.ts' -import { Context } from '../core/router.ts' +import { Context, RouteError } from '../core/router.ts' import { sessions } from '../core/sessions.ts' import { db } from '../db.ts' -import { actions, RpcError } from '../routes/rpc.ts' +import { actions } from '../routes/rpc.ts' import { UserSession } from '../schemas/user.ts' export const ADMINS = ['x', 'stagas'] @@ -10,7 +10,7 @@ export const ADMINS = ['x', 'stagas'] function admins(ctx: Context) { const session = sessions.get(ctx) if (!session || !ADMINS.includes(session.nick)) { - throw new RpcError(403, 'Forbidden') + throw new RouteError(403, 'Forbidden') } } diff --git a/api/actions/login-register.ts b/api/actions/login-register.ts index 1f83b53..ce34995 100644 --- a/api/actions/login-register.ts +++ b/api/actions/login-register.ts @@ -3,38 +3,39 @@ import { hash } from 'jsr:@denorg/scrypt@4.4.4' import { createCookie, randomHash, timeout } from 'utils' import { kv } from '../core/app.ts' import { SALT as salt } from '../core/constants.ts' -import { Context } from '../core/router.ts' +import { Context, RouteError } from '../core/router.ts' import { sendEmail } from '../core/send-email.ts' import { sessions } from '../core/sessions.ts' import { db } from '../db.ts' import { env } from '../env.ts' -import { actions, RpcError } from '../routes/rpc.ts' -import { User, UserLogin, UserRegister, UserSession } from '../schemas/user.ts' +import { actions } from '../routes/rpc.ts' +import { UserLogin, UserRegister, UserSession } from '../schemas/user.ts' import { ADMINS } from './admin.ts' +import { User } from '../models.ts' // const DEBUG = true -class LoginError extends RpcError { +class LoginError extends RouteError { constructor() { super(403, 'Wrong user or password') } } -class UserExistsError extends RpcError { +class UserExistsError extends RouteError { constructor() { super(409, 'User already exists') } } -class UnableToRegisterError extends RpcError { +class UnableToRegisterError extends RouteError { constructor() { super(500, 'Unable to register') } } -class UserEmailAlreadyVerified extends RpcError { +class UserEmailAlreadyVerified extends RouteError { constructor() { super(404, 'Email already verified') } } -class UserNotFound extends RpcError { +class UserNotFound extends RouteError { constructor() { super(404, 'User not found') } } -class TokenNotFound extends RpcError { +class TokenNotFound extends RouteError { constructor() { super(404, 'Token not found') } } @@ -58,7 +59,7 @@ export async function getUser(nickOrEmail: string) { return await getUserByNick(nickOrEmail) || await getUserByEmail(nickOrEmail) } -async function loginUser(ctx: Context, nick: string) { +export async function loginUser(ctx: Context, nick: string) { ctx.log('Login:', nick) const sessionId = randomHash() @@ -80,6 +81,7 @@ async function loginUser(ctx: Context, nick: string) { 'session', sessionId, expires, + 'Path=/', 'SameSite=Strict', 'Secure', 'HttpOnly', @@ -106,7 +108,7 @@ async function generateEmailVerificationToken(email: string) { actions.post.whoami = whoami export async function whoami(ctx: Context) { const session = sessions.get(ctx) - return session + return session ?? null } actions.post.login = login @@ -124,23 +126,66 @@ export async function login(ctx: Context, userLogin: UserLogin) { } actions.post.register = register -export async function register(ctx: Context, userRegister: UserRegister) { - const { nick, email, password } = userRegister - - if (await getUserByNick(nick) || await getUserByEmail(email)) { - throw new UserExistsError() +export async function register(ctx: Context, userRegister: UserRegister, oauthField?: 'oauthGithub') { + const { nick, email } = userRegister + + const userByNick = await getUserByNick(nick) + const userByEmail = await getUserByEmail(email) + + // user nick or email exists + if (userByNick || userByEmail) { + // user has registered with password before and is now logging in with oauth + if (oauthField && userByEmail) { + await db + .updateTable('user') + .where('email', '=', email) + .set('emailVerified', true) + .set(oauthField, true) + .executeTakeFirstOrThrow(UnableToRegisterError) + } + // user has logged in with oauth before and is now registering with password + else if (userByEmail?.emailVerified && 'password' in userRegister && !userByEmail.password) { + await db + .updateTable('user') + .where('email', '=', email) + .set('password', hash(userRegister.password, { salt })) + .executeTakeFirstOrThrow(UnableToRegisterError) + } + else { + throw new UserExistsError() + } } + // user is new + else { + let values: UserRegister + // user registers with password + if ('password' in userRegister) { + values = { + nick, + email, + password: hash(userRegister.password, { salt }) + } + } + // user registers with oauth + else if (oauthField) { + values = { + nick, + email, + emailVerified: true, + // @ts-ignore ts has issues with dynamic keys even though it is typed + [oauthField]: true + } + } + else { + throw new RouteError(400, 'Invalid registration') + } - await db.insertInto('user') - .values({ - nick, - email, - emailVerified: false, - password: hash(password, { salt }) - }) - .executeTakeFirstOrThrow(UnableToRegisterError) + await db.insertInto('user') + .values(values) + .executeTakeFirstOrThrow(UnableToRegisterError) - sendVerificationEmail(ctx, email).catch(ctx.log) + if (!oauthField) sendVerificationEmail(ctx, email).catch(ctx.log) + } return loginUser(ctx, nick) } @@ -173,7 +218,7 @@ If you did not register, simply ignore this email.`, }) if (!result.ok) { - throw new RpcError(result.error.statusCode, result.error.message) + throw new RouteError(result.error.statusCode, result.error.message) } } @@ -248,7 +293,7 @@ If you did not request a password reset, simply ignore this email.`, }) if (!result.ok) { - throw new RpcError(result.error.statusCode, result.error.message) + throw new RouteError(result.error.statusCode, result.error.message) } } @@ -259,9 +304,8 @@ export async function getResetPasswordUser(_ctx: Context, token: string) { if (result.value) { const user = await getUserByNick(result.value) if (user) { - // @ts-ignore remove password before returning - delete user.password - return user as Omit + user.password = null + return user } } diff --git a/api/actions/oauth.ts b/api/actions/oauth.ts new file mode 100644 index 0000000..0430f35 --- /dev/null +++ b/api/actions/oauth.ts @@ -0,0 +1,31 @@ +import { kv } from '../core/app.ts' +import { Context, RouteError } from '../core/router.ts' +import { OAuthLogin } from '../routes/oauth/github.ts' +import { actions } from '../routes/rpc.ts' +import * as loginRegisterActions from './login-register.ts' + +function pascalCase(s: string) { + return s.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase()) +} + +actions.get.getLoginSession = getLoginSession +export async function getLoginSession(_ctx: Context, id: string) { + const entry = await kv.get(['oauth', id]) + if (!entry.value) throw new RouteError(404, 'OAuth session not found') + const session = OAuthLogin.parse(entry.value) + return { login: session.login } +} + +actions.post.registerOAuth = registerOAuth +export async function registerOAuth(ctx: Context, id: string, nick: string) { + const entry = await kv.get(['oauth', id]) + if (!entry.value) throw new RouteError(404, 'OAuth session not found') + const target = 'oauth' + pascalCase(id.split('-')[0]) as 'oauthGithub' + const session = OAuthLogin.parse(entry.value) + return await loginRegisterActions.register(ctx, { + nick, + email: session.email, + // @ts-ignore ts has issues with dynamic keys + [target]: true + }, target) +} diff --git a/api/core/fetch-json.ts b/api/core/fetch-json.ts new file mode 100644 index 0000000..a6d8d2c --- /dev/null +++ b/api/core/fetch-json.ts @@ -0,0 +1,7 @@ +export async function fetchJson(url: string, init: RequestInit) { + const res = await fetch(url, init) + if (!res.ok) { + throw new Error(`Fetch error ${res.status}`) + } + return res.json() +} diff --git a/api/core/middleware.ts b/api/core/middleware.ts index 78b951f..0bc0984 100644 --- a/api/core/middleware.ts +++ b/api/core/middleware.ts @@ -74,6 +74,7 @@ export const files = (root: string): Handler => async ctx => { const { pathname } = ctx.url let file let filepath + let error out: try { filepath = path.join(pathname, 'index.html') @@ -86,10 +87,18 @@ export const files = (root: string): Handler => async ctx => { break out } catch (e) { - ctx.log('Error serving:', filepath, e) + error = e if (e instanceof Deno.errors.NotFound) { - return + try { + filepath = '/index.html' + file = await Deno.open(`${root}${filepath}`, { read: true }) + break out + } + catch (e) { + error = e + } } + ctx.log('Error serving:', filepath, error) } return new Response(null, { status: 500 }) } diff --git a/api/core/router.ts b/api/core/router.ts index ed6a6cb..5215e41 100644 --- a/api/core/router.ts +++ b/api/core/router.ts @@ -2,6 +2,8 @@ import { defer, parseCookie } from 'utils' import { z } from 'zod' import { match } from './match.ts' +const DEBUG = false + const headers: Record = { 'access-control-allow-methods': 'GET, HEAD, OPTIONS, POST, PUT', 'access-control-allow-headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization', @@ -28,6 +30,13 @@ interface Route { handle: Handler } +export class RouteError extends Error { + declare cause: { status: number } + constructor(status: number, message: string) { + super(message, { cause: { status } }) + } +} + function redirect(status: number, location: string) { return new Response(null, { status, headers: { location } }) } @@ -90,6 +99,7 @@ export function Router({ log = console.log }: { log?: typeof console.log } = {}) return async function handler(ctx) { let m: ReturnType> | undefined if (matcher) m = matcher(ctx.url.pathname) + DEBUG && ctx.log('match:', ctx.url.pathname, 'to:', path, m) if (path === null || m) { ctx.params = m && m.params || {} for (const fn of fns) { diff --git a/api/core/server.ts b/api/core/server.ts index 1ac4969..2d0f068 100644 --- a/api/core/server.ts +++ b/api/core/server.ts @@ -1,9 +1,12 @@ import os from 'https://deno.land/x/os_paths@v7.4.0/src/mod.deno.ts' import * as path from 'jsr:@std/path' import * as rpc from '../routes/rpc.ts' +import * as oauthCommon from '../routes/oauth/common.ts' +import * as oauthGitHub from '../routes/oauth/github.ts' import { app } from './app.ts' import { IS_DEV } from './constants.ts' import { cors, files, logger, session, watcher } from './middleware.ts' +import '../actions/oauth.ts' const dist = 'dist' const home = os.home() ?? '~' @@ -21,6 +24,8 @@ app.use(null, [logger]) app.use(null, [cors]) app.use(null, [session]) +oauthCommon.mount(app) +oauthGitHub.mount(app) rpc.mount(app) IS_DEV && app.log('Listening: https://devito.test:8000') diff --git a/api/env.ts b/api/env.ts index 63c0e0d..59820ec 100644 --- a/api/env.ts +++ b/api/env.ts @@ -7,8 +7,13 @@ const Env = z.object({ VITE_API_URL: z.string(), WEB_URL: z.string(), + DATABASE_URL: z.string(), + RESEND_API_KEY: z.string(), + + OAUTH_GITHUB_CLIENT_ID: z.string(), + OAUTH_GITHUB_CLIENT_SECRET: z.string(), }) export const env = Env.parse(Object.assign({ @@ -16,9 +21,14 @@ export const env = Env.parse(Object.assign({ VITE_API_URL: Deno.env.get('VITE_API_URL')!, WEB_URL: Deno.env.get('WEB_URL')!, + DATABASE_URL: Deno.env.get('DATABASE_URL')!, + RESEND_API_KEY: Deno.env.get('RESEND_API_KEY')!, + OAUTH_GITHUB_CLIENT_ID: Deno.env.get('OAUTH_GITHUB_CLIENT_ID')!, + OAUTH_GITHUB_CLIENT_SECRET: Deno.env.get('OAUTH_GITHUB_CLIENT_SECRET')!, + } satisfies z.infer, await load({ envPath: IS_DEV ? '.env.development' : '.env' }))) diff --git a/api/models.ts b/api/models.ts index bf60ce9..d5d1906 100644 --- a/api/models.ts +++ b/api/models.ts @@ -13,7 +13,8 @@ export const User = z.object({ nick: z.string(), email: z.string(), emailVerified: z.boolean().nullish(), - password: z.string(), + password: z.string().nullish(), + oauthGithub: z.boolean().nullish(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }) @@ -22,7 +23,8 @@ export interface User { email: string; emailVerified: Generated; nick: string; - password: string; + oauthGithub: boolean | null; + password: string | null; updatedAt: Generated; } diff --git a/api/routes/oauth/common.ts b/api/routes/oauth/common.ts new file mode 100644 index 0000000..d322c91 --- /dev/null +++ b/api/routes/oauth/common.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { Router } from '../../core/router.ts' +import { env } from '../../env.ts' + +const OAuthStart = z.object({ + provider: z.enum(['github']), + redirect_to: z.string().regex(/[a-z0-9/_-]/gi).optional() +}) + +export function mount(app: Router) { + const { + OAUTH_GITHUB_CLIENT_ID: client_id, + } = env + + app.get('/oauth/start', [ctx => { + const { provider, redirect_to } = OAuthStart.parse(Object.fromEntries(ctx.url.searchParams.entries())) + switch (provider) { + case 'github': { + const url = new URL('https://github.com/login/oauth/authorize') + url.searchParams.set('client_id', client_id) + url.searchParams.set('state', redirect_to ?? '/') + return ctx.redirect(302, url.href) + } + } + }]) +} diff --git a/api/routes/oauth/github.ts b/api/routes/oauth/github.ts new file mode 100644 index 0000000..88f3906 --- /dev/null +++ b/api/routes/oauth/github.ts @@ -0,0 +1,131 @@ +import { createCookie, randomHash } from 'utils' +import { z } from 'zod' +import { getUserByEmail, loginUser } from '../../actions/login-register.ts' +import { kv } from '../../core/app.ts' +import { fetchJson } from '../../core/fetch-json.ts' +import { RouteError, Router } from '../../core/router.ts' +import { env } from '../../env.ts' + +const OAuthError = z.object({ + error: z.string(), + error_description: z.string(), + error_uri: z.string() +}) + +const OAuthCallback = z.union([ + z.object({ + code: z.string(), + state: z.string().optional() + }), + OAuthError +]) + +export const OAuthLogin = z.object({ + login: z.string().optional(), + email: z.string(), + access_token: z.string(), + redirect_to: z.string(), +}) +export type OAuthLogin = z.infer + +const OAuthAccessToken = z.union([ + z.object({ + access_token: z.string(), + scope: z.string(), + token_type: z.string() + }), + OAuthError +]) + +const OAuthUser = z.union([ + z.object({ + login: z.string(), + email: z.string(), + }), + OAuthError +]) + +const headers = { + 'content-type': 'application/json', + 'user-agent': 'cfw-oauth-login', + accept: 'application/json', +} + +export function mount(app: Router) { + const { + OAUTH_GITHUB_CLIENT_ID: client_id, + OAUTH_GITHUB_CLIENT_SECRET: client_secret + } = env + + app.get('/oauth/github', [async ctx => { + const origin = ( + ctx.request.headers.get('origin') ?? + ctx.request.headers.get('referer') ?? + env.WEB_URL + ).replace(/\/$/, '') + + const cb = OAuthCallback.parse(Object.fromEntries(ctx.url.searchParams.entries())) + if ('error' in cb) throw new RouteError(401, cb.error_description) + + const { code, state } = cb + const redirect_to = state ?? '/' + + // get access token + const auth = OAuthAccessToken.parse(await fetchJson('https://github.com/login/oauth/access_token', { + method: 'POST', + headers, + body: JSON.stringify({ client_id, client_secret, code }), + })) + if ('error' in auth) throw new RouteError(401, auth.error_description) + + // get user info using the token + const user = OAuthUser.parse(await fetchJson('https://api.github.com/user', { + headers: { + ...headers, + Authorization: `Bearer ${auth.access_token}` + } + })) + if ('error' in user) throw new RouteError(401, user.error_description) + + const userByEmail = await getUserByEmail(user.email) + // user has already registered + if (userByEmail?.oauthGithub) { + await loginUser(ctx, userByEmail.nick) + const url = new URL(`${origin}/oauth/complete`) + url.searchParams.set('redirect_to', redirect_to) + const res = ctx.redirect(302, url.href) + return res + } + + // save login session + const id = `github-${randomHash()}` + const now = new Date() + const expires = new Date(now) + expires.setMinutes(expires.getMinutes() + 30) + + kv.set(['oauth', id], { + login: user.login, + email: user.email, + access_token: auth.access_token, + redirect_to + } satisfies z.infer, { + expireIn: expires.getTime() - now.getTime() + }) + + // redirect user to register + const url = new URL(`${origin}/oauth/register`) + url.searchParams.set('id', id) + const res = ctx.redirect(302, url.href) + + res.headers.set('set-cookie', createCookie( + 'oauth', + id, + expires, + 'HttpOnly', + 'Secure', + 'SameSite=Strict' + )) + + return res + }]) +} diff --git a/api/routes/rpc.ts b/api/routes/rpc.ts index 3f4fd26..c26e341 100644 --- a/api/routes/rpc.ts +++ b/api/routes/rpc.ts @@ -1,6 +1,6 @@ import { defer } from 'utils' import { z } from 'zod' -import type { Router } from '../core/router.ts' +import { RouteError, type Router } from '../core/router.ts' import { sessions } from '../core/sessions.ts' const DEBUG = false @@ -18,20 +18,13 @@ export const actions = { post: {} as Actions, } -export class RpcError extends Error { - declare cause: { status: number } - constructor(status: number, message: string) { - super(message, { cause: { status } }) - } -} - const headers = { 'content-type': 'application/javascript' } export function mount(app: Router) { app.use('/rpc', [async ctx => { const url = new URL(ctx.request.url) const fn: string | null = url.searchParams.get('fn') - if (!fn) throw new RpcError(400, 'Missing function name') + if (!fn) throw new RouteError(400, 'Missing function name') let args: unknown[] @@ -53,11 +46,12 @@ export function mount(app: Router) { } default: - throw new RpcError(405, 'Method not allowed') + throw new RouteError(405, 'Method not allowed') } - const action = actions[ctx.request.method.toLowerCase() as 'get'][fn] - if (!action) throw new Error('Rpc call not found: ' + fn) + const method = ctx.request.method.toLowerCase() as 'get' + const action = actions[method][fn] + if (!action) throw new Error(`Rpc call not found: ${method} ${fn}`) const before = new Date() using _ = defer(() => { @@ -81,7 +75,8 @@ export function mount(app: Router) { }) } catch (error) { - if (error instanceof RpcError) { + console.error(error) + if (error instanceof RouteError) { return new Response(JSON.stringify({ error: error.message }), { status: error.cause.status, headers diff --git a/api/schemas/user.ts b/api/schemas/user.ts index 663a80c..5ca112e 100644 --- a/api/schemas/user.ts +++ b/api/schemas/user.ts @@ -1,25 +1,22 @@ import { z } from 'zod' -export type User = z.infer -export const User = z.object({ - nick: z.string(), - email: z.string(), - emailVerified: z.boolean(), - password: z.string().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}) - export const UserGet = z.object({ nick: z.string() }) export type UserRegister = z.infer -export const UserRegister = z.object({ - nick: z.string(), - email: z.string(), - password: z.string().min(1), // TODO: .min(10) -}) +export const UserRegister = z.union([ + z.object({ + nick: z.string(), + email: z.string(), + password: z.string().min(1), // TODO: .min(10) + }), + z.object({ + nick: z.string(), + email: z.string(), + emailVerified: z.boolean().optional(), + }), +]) export type UserLogin = z.infer export const UserLogin = z.object({ diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..f4fd71f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,9 @@ +coverage: + status: + patch: + default: + target: 10% + project: + default: + target: 10% + diff --git a/kysely.config.ts b/kysely.config.ts index c49a997..293ff7f 100644 --- a/kysely.config.ts +++ b/kysely.config.ts @@ -13,7 +13,6 @@ dotenv.config({ }) if (DATABASE_URL) process.env.DATABASE_URL = DATABASE_URL - const connectionString = process.env.DATABASE_URL if (!connectionString) { throw new Error('kysely: No connectionString found.') diff --git a/migrations/1727778500969_user-table.ts b/migrations/1727778500969_user-table.ts index d57d1cf..282ec58 100644 --- a/migrations/1727778500969_user-table.ts +++ b/migrations/1727778500969_user-table.ts @@ -16,9 +16,8 @@ export async function up(db: Kysely): Promise { .addColumn('emailVerified', 'boolean', col => col.defaultTo(false) ) - .addColumn('password', 'text', col => - col.unique().notNull() - ) + .addColumn('password', 'text') + .addColumn('oauthGithub', 'boolean') .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql`now()`).notNull() ) diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 3d5745e..795edee 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -5,6 +5,8 @@ import { whoami } from '../rpc/login-register.ts' import { state } from '../state.ts' import { About } from './About.tsx' import { Home } from './Home.tsx' +import { OAuthRegister } from './OAuthRegister.tsx' +import { env } from '../env.ts' export function App() { using $ = Sigui() @@ -24,6 +26,8 @@ export function App() { Page: {() => state.url.pathname}
{() => { + if (state.user === undefined) return
Loading...
+ switch (state.url.pathname) { case '/': return @@ -36,6 +40,20 @@ export function App() { case '/reset-password': return + + case '/oauth/popup': + const provider = state.url.searchParams.get('provider')! + location.href = `${env.VITE_API_URL}/oauth/start?provider=${provider}&redirect_to=/` + return
+ + case '/oauth/register': + return + + case '/oauth/complete': + // Hack: triggering a localStorage write we listen to + // window.onstorage and we can close the popup automatically. + localStorage.oauth = Math.random() + return
Logging in...
} }} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 132d165..9df3559 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,10 +1,32 @@ +import { on } from 'utils' import { Login } from '../comp/Login.tsx' import { Logout } from '../comp/Logout.tsx' import { Register } from '../comp/Register.tsx' +import { whoami } from '../rpc/login-register.ts' import { state } from '../state.ts' import { Link } from '../ui/Link.tsx' export function Home() { + function oauthLogin(provider: string) { + const h = 700 + const w = 500 + const x = window.outerWidth / 2 + window.screenX - (w / 2) + const y = window.outerHeight / 2 + window.screenY - (h / 2) + + const popup = window.open( + `${location.origin}/oauth/popup?provider=${provider}`, + 'oauth', + `width=${w}, height=${h}, top=${y}, left=${x}` + ) + + if (!popup) alert('Something went wrong') + + on(window, 'storage', () => { + popup!.close() + whoami().then(user => state.user = user) + }, { once: true }) + } + return
{() => state.user === undefined ?
Loading...
@@ -13,6 +35,7 @@ export function Home() {
+
:
@@ -21,7 +44,6 @@ export function Home() { {state.user.isAdmin && Admin} About
- }
} diff --git a/src/pages/OAuthRegister.tsx b/src/pages/OAuthRegister.tsx new file mode 100644 index 0000000..e6a53fc --- /dev/null +++ b/src/pages/OAuthRegister.tsx @@ -0,0 +1,42 @@ +import { Sigui } from 'sigui' +import { z } from 'zod' +import * as oauth from '../rpc/oauth.ts' +import { go } from '../ui/Link.tsx' +import { parseForm } from '../util/parse-form.ts' + +const formSchema = z.object({ + nick: z.string(), +}) + +export function OAuthRegister() { + using $ = Sigui() + + const id = new URL(location.href).searchParams.get('id') + if (!id) return
id not found
+ + const info = $({ + nick: undefined as undefined | string, + error: null as null | string + }) + + oauth.getLoginSession(id).then(loginSession => { + info.nick = loginSession.login + }) + + return
+ Pick a nick: + +
{ + ev.preventDefault() + const { nick } = parseForm(ev.target as HTMLFormElement, formSchema) + oauth.registerOAuth(id, nick) + .then(() => go('/oauth/complete')) + .catch(error => info.error = error.message) + }}> + info.nick} spellcheck="false" autocomplete="nickname" /> + +
+ {() => info.error} +
+
+} diff --git a/src/rpc/oauth.ts b/src/rpc/oauth.ts new file mode 100644 index 0000000..3dc53e5 --- /dev/null +++ b/src/rpc/oauth.ts @@ -0,0 +1,5 @@ +import type * as actions from '../../api/actions/oauth.ts' +import { rpc } from '../../lib/rpc.ts' + +export const getLoginSession = rpc('GET', 'getLoginSession') +export const registerOAuth = rpc('POST', 'registerOAuth') diff --git a/src/test/e2e.test.tsx b/src/test/e2e.test.tsx index e145459..1320477 100644 --- a/src/test/e2e.test.tsx +++ b/src/test/e2e.test.tsx @@ -18,6 +18,9 @@ describe('App', () => { state.url = new URL('/about', location.origin) state.url = new URL('/verify-email', location.origin) state.url = new URL('/reset-password', location.origin) + state.url = new URL('/oauth/popup', location.origin) + state.url = new URL('/oauth/register', location.origin) + state.url = new URL('/oauth/complete', location.origin) }) it('user', async () => {