diff --git a/apps/csbie-server/src/routes/admin.ts b/apps/csbie-server/src/routes/admin.ts index 600d8cc..6c3d72c 100644 --- a/apps/csbie-server/src/routes/admin.ts +++ b/apps/csbie-server/src/routes/admin.ts @@ -20,14 +20,14 @@ export type StoredSbiPasskeySecret = { deviceId?: string } -const requireAuth: MiddlewareHandler = async (c, next) => { - if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401) +const requireOwnerSession: MiddlewareHandler = async (c, next) => { + if (c.get('auth').type !== 'session') return c.json({ error: 'unauthorized' }, 401) await next() } export const createAdminRoutes = () => { const app = new Hono() - app.use('*', requireAuth) + app.use('*', requireOwnerSession) app.get('/api-keys', async (c) => c.json({ apiKeys: await listApiKeys(c.get('db')) })) diff --git a/apps/csbie-server/src/routes/oauth.ts b/apps/csbie-server/src/routes/oauth.ts index 28ec5d1..d20a168 100644 --- a/apps/csbie-server/src/routes/oauth.ts +++ b/apps/csbie-server/src/routes/oauth.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm' import { Hono } from 'hono' +import type { Context } from 'hono' import type { AppBindings } from '../context' import { oauthClients } from '../db/schema' import type { ApiKeySettings } from '../security/api-keys' @@ -31,11 +32,13 @@ const redirectUriAllowed = (redirectUri: string, registeredUris: string[]) => { }) } +const isOwnerSession = (c: Context) => c.get('auth').type === 'session' + export const createOAuthRoutes = () => { const app = new Hono() app.get('/client/:id', async (c) => { - if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401) + if (!isOwnerSession(c)) return c.json({ error: 'unauthorized' }, 401) const [client] = await c .get('db') .select() @@ -46,7 +49,7 @@ export const createOAuthRoutes = () => { }) app.post('/approve', async (c) => { - if (!c.get('authenticated')) return c.json({ error: 'unauthorized' }, 401) + if (!isOwnerSession(c)) return c.json({ error: 'unauthorized' }, 401) const body = await c.req.json<{ clientId?: string redirectUri?: string diff --git a/apps/csbie-ui/src/styles/ui.ts b/apps/csbie-ui/src/styles/ui.ts index ca799cb..5c46a30 100644 --- a/apps/csbie-ui/src/styles/ui.ts +++ b/apps/csbie-ui/src/styles/ui.ts @@ -1,36 +1,40 @@ export const ui = { - appShell: 'grid h-dvh overflow-hidden grid-cols-[7rem_minmax(0,1fr)] bg-[#101418] text-[#e3e3e9]', - sidebar: 'flex h-full min-h-0 flex-col items-center border-r border-[#2f3338] bg-[#191c20] py-4', + appShell: + 'grid h-dvh grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-[#101418] text-[#e3e3e9] lg:grid-cols-[7rem_minmax(0,1fr)] lg:grid-rows-none', + sidebar: + 'order-2 flex h-20 min-h-0 items-center overflow-x-auto border-t border-[#2f3338] bg-[#191c20] px-2 py-2 lg:order-none lg:h-full lg:flex-col lg:overflow-visible lg:border-t-0 lg:border-r lg:px-0 lg:py-4', brandMark: - 'grid h-14 w-14 place-items-center rounded-[18px] bg-[#a8c7fa] text-3xl font-black text-[#0b305f] shadow-lg shadow-black/20', - navStack: 'mt-10 grid w-full gap-4 px-2', + 'hidden h-14 w-14 place-items-center rounded-[18px] bg-[#a8c7fa] text-3xl font-black text-[#0b305f] shadow-lg shadow-black/20 lg:grid', + navStack: + 'flex w-full min-w-max justify-around gap-2 lg:mt-10 lg:grid lg:min-w-0 lg:gap-4 lg:px-2', navButton: - 'grid min-h-16 place-items-center gap-1 rounded-[20px] bg-transparent p-1 text-xs font-semibold text-[#c3c7cf] transition hover:bg-[#22272e]', + 'grid min-h-14 min-w-16 place-items-center gap-1 rounded-[20px] bg-transparent p-1 text-xs font-semibold text-[#c3c7cf] transition hover:bg-[#22272e] lg:min-h-16 lg:min-w-0', navButtonActive: 'text-[#d3e3fd]', - navIcon: 'grid h-8 w-16 place-items-center rounded-full text-[#c3c7cf]', + navIcon: 'grid h-8 w-12 place-items-center rounded-full text-[#c3c7cf] lg:w-16', navIconActive: 'bg-[#263141] text-[#d3e3fd]', - workspace: 'flex min-h-0 min-w-0 flex-col gap-7 overflow-y-auto px-8 pt-8 pb-8', - topbar: 'flex shrink-0 items-center justify-between gap-4', + workspace: + 'order-1 flex min-h-0 min-w-0 flex-col gap-5 overflow-y-auto px-4 pt-4 pb-5 sm:px-6 sm:pt-6 lg:order-none lg:gap-7 lg:px-8 lg:pt-8 lg:pb-8', + topbar: 'flex shrink-0 flex-wrap items-center justify-between gap-4', tradeTopbar: 'hidden', - authPanel: 'grid justify-center pt-12', + authPanel: 'grid justify-center pt-6 sm:pt-12', panel: - 'grid content-start gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-lg shadow-black/15', - loginPanel: 'w-[28rem]', - panelHead: 'flex items-center justify-between gap-3', + 'grid content-start gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-4 shadow-lg shadow-black/15 sm:p-6', + loginPanel: 'w-full max-w-[28rem]', + panelHead: 'flex flex-wrap items-center justify-between gap-3', eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]', dashboardGrid: - 'grid min-h-0 flex-1 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] grid-rows-[auto_minmax(0,1fr)] items-stretch gap-6 overflow-hidden', + 'grid min-h-0 flex-1 grid-cols-1 items-stretch gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-6 lg:overflow-hidden', metricPanel: - 'grid min-h-40 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', + 'grid min-h-36 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:min-h-40 sm:p-7', assetOverviewPanel: - 'grid min-h-40 grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] items-center gap-x-7 gap-y-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', + 'grid min-h-40 grid-cols-1 items-center gap-x-7 gap-y-5 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] sm:p-7', assetOverviewHead: 'grid gap-2', assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]', assetBreakdownPanel: 'grid gap-3', assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]', metricLabel: 'text-xs font-extrabold text-[#9aa0a9]', - metricValue: 'text-3xl font-black', + metricValue: 'break-words text-2xl font-black sm:text-3xl', positive: 'text-[#40dba2]', negative: 'text-[#ffb4ab]', miniProgress: 'block h-2 w-full overflow-hidden rounded-full bg-[#33383f]', @@ -48,48 +52,51 @@ export const ui = { assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]', assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]', holdingsPanel: - 'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', - holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-hidden', + 'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7', + holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden', holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto', holdingsHead: - 'grid grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d]', + 'hidden grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d] md:grid', holdingRow: - 'grid min-h-16 grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 rounded-2xl bg-transparent px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930]', + 'grid min-h-16 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-2xl bg-[#111418] px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] md:grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] md:gap-4 md:bg-transparent', typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]', muted: 'text-[#8f949d]', portfolioHistory: - 'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15', - historyList: 'flex min-h-0 flex-1 flex-col overflow-hidden', - historyRows: 'grid min-h-0 flex-1 content-start gap-7 overflow-y-auto', + 'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7', + historyList: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden', + historyRows: 'grid min-h-0 flex-1 content-start gap-5 overflow-y-auto lg:gap-7', emptyState: 'flex flex-1 items-center justify-center py-8 text-center', miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3', - tradeLayout: 'trade-layout grid w-full min-h-[calc(100dvh-6rem)] grid-cols-5 items-stretch gap-5', + tradeLayout: + 'trade-layout grid w-full min-w-0 grid-cols-1 items-stretch gap-5 lg:min-h-[calc(100dvh-6rem)] lg:grid-cols-5', watchlist: - 'order-3 pl-2 col-span-1 grid h-full min-h-[calc(100dvh-6rem)] content-start overflow-y-auto border-l border-[#30343a] bg-[#101418] shadow-none', + 'order-3 grid min-w-0 content-start gap-2 overflow-visible border-t border-[#30343a] bg-[#101418] pt-4 shadow-none lg:col-span-1 lg:h-full lg:min-h-[calc(100dvh-6rem)] lg:overflow-y-auto lg:border-t-0 lg:border-l lg:pt-0 lg:pl-2', watchSearch: 'm-3 inline-flex min-h-11 items-center gap-2 rounded-full bg-[#111418] px-4 text-left font-medium text-[#9aa0a9] outline outline-1 outline-[#33383f] transition hover:bg-[#22272e] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]', watchRow: 'grid min-h-18 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border-l-2 border-transparent px-4 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#d3e3fd]', watchRowActive: 'bg-gray-900', centerStack: 'order-1 col-span-3 flex min-h-0 h-full flex-col gap-5', - stockMetadataPanel: 'grid min-h-0 gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none', - stockBodyGrid: 'flex-1 min-h-0 grid grid-rows-3 gap-5', + stockMetadataPanel: + 'grid min-h-0 gap-4 rounded-[24px] border-0 bg-[#101418] p-0 shadow-none sm:p-6', + stockBodyGrid: 'grid min-h-0 flex-1 gap-5 lg:grid-rows-3', tradeSeparator: 'order-3 hidden', stockPanel: - 'grid min-h-0 h-full grid-rows-[auto_auto_1fr] gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none', - stockPanelSpan: 'row-span-2', - stockTitle: 'flex items-start justify-between gap-3', + 'grid min-h-0 h-full grid-rows-[auto_auto_1fr] gap-4 rounded-[24px] border-0 bg-[#101418] p-0 shadow-none sm:p-6', + stockPanelSpan: 'lg:row-span-2', + stockTitle: 'flex flex-wrap items-start justify-between gap-3', stockHoldingNote: 'inline-flex w-fit items-center rounded-full border border-[#2f343b] bg-[#111418] px-4 py-2 text-sm font-semibold text-[#c3c7cf]', - quoteBox: 'grid justify-items-end gap-1', - periodTabs: 'flex flex-wrap justify-end gap-2', + quoteBox: 'grid justify-items-start gap-1 sm:justify-items-end', + periodTabs: 'flex flex-wrap justify-start gap-2 sm:justify-end', periodButton: 'min-h-7 rounded-full bg-transparent px-3 text-xs font-bold text-[#8f949d]', periodButtonActive: 'bg-[#d3e3fd] text-[#102033]', - chartActions: 'flex items-center justify-between gap-4', + chartActions: 'flex flex-wrap items-center justify-between gap-4', smallTabs: 'grid grid-cols-2 gap-1 rounded-full bg-[#111418] p-1', smallTab: 'min-h-8 rounded-full bg-transparent px-3 text-xs font-bold text-[#9aa0a9]', smallTabActive: 'bg-[#263141] text-[#d3e3fd]', - chartBox: 'mt-1 min-h-0 h-full min-h-[14rem] overflow-hidden rounded-[20px] bg-[#111418]', + chartBox: + 'mt-1 h-72 min-h-0 overflow-hidden rounded-[20px] bg-[#111418] lg:h-full lg:min-h-[14rem]', chartLine: 'fill-none stroke-[#40dba2] stroke-[3]', boxplot: 'grid min-h-36 content-center gap-5 rounded-[24px] bg-[#111418] p-7', boxplotScale: 'flex justify-between text-xs text-[#8f949d]', @@ -98,7 +105,7 @@ export const ui = { whisker: 'absolute top-9 h-3 border-x-2 border-[#c3c7cf]', box: 'absolute top-6 h-9 rounded-xl border-2 border-[#a8c7fa] bg-[#a8c7fa]/25', median: 'absolute top-4 h-12 border-l-4 border-[#fdd663]', - infoTabPanel: 'grid gap-0 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none', + infoTabPanel: 'grid gap-0 rounded-[24px] border-0 bg-[#101418] p-0 shadow-none sm:p-6', infoTabList: 'flex gap-6 border-b border-[#33383f]', infoTabButton: 'relative min-h-10 px-1 text-sm font-black text-[#8f949d] transition hover:text-[#d3e3fd] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d3e3fd]', @@ -106,12 +113,12 @@ export const ui = { infoTabIndicator: 'absolute inset-x-0 bottom-[-1px] block h-0.5 rounded-full bg-[#d3e3fd]', infoTabBody: 'overflow-hidden pt-4', infoTabBodyInner: 'relative min-h-[9.5rem]', - detailGrid: 'grid grid-cols-3 gap-3', + detailGrid: 'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3', detailItem: 'grid min-h-16 gap-1 rounded-[14px] bg-[#111418] p-3', holdingEmpty: 'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#8f949d]', ticketPanel: - 'order-2 col-span-1 flex h-full min-h-0 flex-col gap-4 overflow-hidden border-0 bg-[#101418] p-6 shadow-none', + 'order-2 col-span-1 flex h-full min-h-0 flex-col gap-4 overflow-visible border-0 bg-[#101418] p-0 shadow-none sm:p-6 lg:overflow-hidden', ticketTop: 'shrink-0', ticketScroll: 'min-h-0 flex-1 overflow-y-auto pr-1', ticketScrollInner: 'grid content-start gap-3', @@ -134,7 +141,7 @@ export const ui = { input: 'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition placeholder:text-[#747982] focus:border-[#a8c7fa]', estimateSummary: - 'flex items-center justify-between rounded-[18px] border border-[#2f343b] bg-[#111418] px-5 py-4', + 'flex flex-wrap items-center justify-between gap-2 rounded-[18px] border border-[#2f343b] bg-[#111418] px-5 py-4', holdingNote: 'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#c3c7cf]', actions: 'grid gap-3', @@ -146,25 +153,25 @@ export const ui = { 'inline-flex min-h-10 items-center justify-center gap-2 rounded-full border border-[#4a5058] bg-transparent px-5 font-extrabold text-[#d3e3fd] transition hover:bg-[#263141]', list: 'grid gap-2', orderRow: - 'grid grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4', + 'grid grid-cols-2 items-center gap-3 rounded-[20px] bg-[#111418] p-4 md:grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_auto]', statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]', pendingBadge: 'bg-[#4a3720] text-[#ffddb3]', - apiLayout: 'grid grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] gap-7', + apiLayout: 'grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] lg:gap-7', settingsLayout: 'grid gap-7', - row: 'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4', + row: 'grid grid-cols-1 items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[minmax(0,1fr)_auto]', rowActions: 'flex gap-2', keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4', profileRow: - 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4', + 'grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[auto_minmax(0,1fr)_auto]', searchOverlay: 'fixed inset-0 z-20 bg-[#101418]/75 backdrop-blur-sm', searchSheet: - 'mx-auto mt-4 grid max-h-[85vh] w-[48rem] gap-3 overflow-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24]/85 p-4 shadow-2xl shadow-black/35', - searchInputRow: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3', - filterRow: 'grid grid-cols-2 gap-3', + 'mx-auto mt-2 grid max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[48rem] gap-3 overflow-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24]/85 p-3 shadow-2xl shadow-black/35 sm:mt-4 sm:max-h-[85vh] sm:w-[calc(100vw-2rem)] sm:p-4 md:w-[48rem]', + searchInputRow: 'grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto]', + filterRow: 'grid grid-cols-1 gap-3 sm:grid-cols-2', searchResults: 'grid gap-2', searchLoading: 'flex min-h-32 flex-col items-center justify-center gap-3 py-8 text-[#9aa0a8]', searchResult: - 'flex min-h-16 items-center justify-between gap-4 rounded-[20px] border border-transparent bg-[#111418] p-4 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]', + 'flex min-h-16 flex-wrap items-center justify-between gap-4 rounded-[20px] border border-transparent bg-[#111418] p-4 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]', searchResultActive: 'border-l-4 border-[#d3e3fd] bg-[#242930]', searchResultFocus: 'bg-[#2a323a] outline outline-2 outline-[#d3e3fd] outline-offset-[-2px] shadow-[0_0_0_1px_rgba(211,227,253,0.35)]', diff --git a/apps/csbie/src/index.ts b/apps/csbie/src/index.ts index 16f0ec1..bea8d2f 100644 --- a/apps/csbie/src/index.ts +++ b/apps/csbie/src/index.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { extname, join } from 'node:path' +import { extname, isAbsolute, join, relative, resolve } from 'node:path' import { Hono } from 'hono' import { createServerApp } from '@repo/csbie-server/app' import { loadConfig } from '@repo/csbie-server/config' @@ -9,7 +9,7 @@ const config = loadConfig() const db = createDb(config.databasePath) const api = createServerApp(db, config) const app = new Hono() -const uiDist = join(import.meta.dir, '../../csbie-ui/dist') +const uiDist = resolve(import.meta.dir, '../../csbie-ui/dist') const contentTypes: Record = { '.css': 'text/css; charset=utf-8', @@ -23,11 +23,22 @@ const contentTypes: Record = { app.route('/api', api.app) app.route('/', api.app) +const isInsideUiDist = (path: string) => { + const pathFromUiDist = relative(uiDist, path) + return pathFromUiDist === '' || (!pathFromUiDist.startsWith('..') && !isAbsolute(pathFromUiDist)) +} + const serveUi = async (request: Request) => { const url = new URL(request.url) - const pathname = decodeURIComponent(url.pathname) + let pathname: string + try { + pathname = decodeURIComponent(url.pathname) + } catch { + return new Response('Bad Request', { status: 400 }) + } const relativePath = pathname === '/' ? 'index.html' : pathname.slice(1) - const filePath = join(uiDist, relativePath) + const filePath = resolve(uiDist, relativePath) + if (!isInsideUiDist(filePath)) return new Response('Not Found', { status: 404 }) const resolvedPath = existsSync(filePath) ? filePath : join(uiDist, 'index.html') const file = Bun.file(resolvedPath) const headers = new Headers()