From 70e5c6a377db53f59e6dea5a6c9bae6211352161 Mon Sep 17 00:00:00 2001 From: Morgan Ney Date: Wed, 29 Nov 2023 20:31:15 -0600 Subject: [PATCH] feat: goog authn backend session. (#97) --- .env.example | 5 + initdb.d/create.sql | 7 +- package-lock.json | 153 +++++++++++++++++- packages/api/.env | 12 +- packages/api/package.json | 2 + packages/api/src/db.ts | 13 ++ packages/api/src/handlers/authn.ts | 106 ++++++++++++ packages/api/src/index.ts | 6 +- packages/api/src/routes/authn.ts | 10 ++ packages/api/src/session.d.ts | 11 ++ packages/components/src/alert/mod.tsx | 2 +- packages/ui/assets/svg/logo-tile.svg | 1 + packages/ui/index.html | 10 +- packages/ui/package.json | 1 + packages/ui/src/api/authn.ts | 21 +++ packages/ui/src/api/transport.ts | 18 ++- packages/ui/src/components/signIn.tsx | 36 +++++ packages/ui/src/globals.tsx | 2 + .../location/components/userLocator.tsx | 40 ++--- packages/ui/src/types.ts | 14 ++ packages/ui/tsconfig.json | 2 +- packages/ui/vite.config.ts | 6 +- 22 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 packages/api/src/db.ts create mode 100644 packages/api/src/handlers/authn.ts create mode 100644 packages/api/src/routes/authn.ts create mode 100644 packages/ui/assets/svg/logo-tile.svg create mode 100644 packages/ui/src/api/authn.ts diff --git a/.env.example b/.env.example index 3613c04..64cfa59 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ +BM_POSTGRES_PASSWORD=secret +BM_POSTGRES_USER=user +BM_POSTGRES_DB=db BM_COOKIE_SECRET=ride_the_bus BM_COOKIE_SECURE=false BM_COOKIE_SAMESITE=lax BM_SESSION_STORE=memory BM_REDIS_HOST=redis://session +SSO_GOOG_CLIENT_ID=goog_sso_id +SSO_GOOG_CLIENT_SECRET=goog_sso_secret DEBUG= diff --git a/initdb.d/create.sql b/initdb.d/create.sql index da557f7..5f58554 100644 --- a/initdb.d/create.sql +++ b/initdb.d/create.sql @@ -5,13 +5,14 @@ CREATE TABLE IF NOT EXISTS "sso_provider" ( "client_secret" varchar(128) NOT NULL ) WITH (oids = false); -CREATE TABLE IF NOT EXISTS "user" ( +CREATE TABLE IF NOT EXISTS "rider" ( "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + "sub" varchar(128) UNIQUE NOT NULL, + "email" varchar(128) UNIQUE NOT NULL, "sso_provider" integer NOT NULL, - "email" varchar(128) NOT NULL, "given_name" varchar(32) NOT NULL, "family_name" varchar(32), + "full_name" varchar(64) NOT NULL, "last_login" timestamptz NOT NULL, - UNIQUE("sso_provider", "email"), FOREIGN KEY (sso_provider) REFERENCES sso_provider(id) ON UPDATE CASCADE ON DELETE CASCADE ) WITH (oids = false); diff --git a/package-lock.json b/package-lock.json index 0f46075..53c9116 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5852,6 +5852,12 @@ "@types/node": "*" } }, + "node_modules/@types/google.accounts": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.14.tgz", + "integrity": "sha512-HqIVkVzpiLWhlajhQQd4rIV7czanFvXblJI2J1fSrL+VKQuQwwZ63m35D/mI0flsqKE6p/hNrAG0Yn4FD6JvNA==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "dev": true, @@ -6838,7 +6844,6 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, "funding": [ { "type": "github", @@ -6901,6 +6906,14 @@ "node": ">=0.6" } }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "dev": true, @@ -7329,6 +7342,11 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "dev": true, @@ -8679,6 +8697,14 @@ "node": ">= 0.8.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -9647,7 +9673,6 @@ }, "node_modules/extend": { "version": "3.0.2", - "dev": true, "license": "MIT" }, "node_modules/extract-zip": { @@ -10155,6 +10180,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.1.1.tgz", + "integrity": "sha512-bw8smrX+XlAoo9o1JAksBwX+hi/RG15J+NTSxmNPIclKC3ZVK6C2afwY8OSdRvOK0+ZLecUJYtj2MmjOt3Dm0w==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -10456,6 +10530,22 @@ "node": ">=0.6.0" } }, + "node_modules/google-auth-library": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.2.0.tgz", + "integrity": "sha512-1oV3p0JhNEhVbj26eF3FAJcv9MXXQt4S0wcvKZaDbl4oHq5V3UJoSbsGZGQNcjoCdhW4kDSwOs11wLlHog3fgQ==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.0.0", + "gcp-metadata": "^6.0.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.0.1", "dev": true, @@ -10477,6 +10567,18 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.0.1.tgz", + "integrity": "sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "dev": true, @@ -11209,7 +11311,6 @@ }, "node_modules/is-stream": { "version": "2.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11630,6 +11731,14 @@ "node": ">=4" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "dev": true, @@ -11688,6 +11797,25 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.3", "dev": true, @@ -12521,7 +12649,6 @@ }, "node_modules/node-fetch": { "version": "2.7.0", - "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -13268,6 +13395,18 @@ "postcss": "^8.2.9" } }, + "node_modules/postgres": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.3.tgz", + "integrity": "sha512-iHJn4+M9vbTdHSdDzNkC0crHq+1CUdFhx+YqCE+SqWxPjm+Zu63jq7yZborOBF64c8pc58O5uMudyL1FQcHacA==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/precinct": { "version": "8.3.1", "dev": true, @@ -15967,7 +16106,6 @@ }, "node_modules/tr46": { "version": "0.0.3", - "dev": true, "license": "MIT" }, "node_modules/trim-newlines": { @@ -16632,7 +16770,6 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/webpack-sources": { @@ -16650,7 +16787,6 @@ }, "node_modules/whatwg-url": { "version": "5.0.0", - "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -16927,9 +17063,11 @@ "debug": "^2.6.9", "express": "^4.18.2", "express-session": "^1.17.3", + "google-auth-library": "^9.2.0", "helmet": "^7.1.0", "http-errors": "^2.0.0", "morgan": "^1.10.0", + "postgres": "^3.4.3", "redis": "^4.6.10", "restbus": "^2.2.0" }, @@ -17020,6 +17158,7 @@ }, "devDependencies": { "@types/geojson": "^7946.0.10", + "@types/google.accounts": "^0.0.14", "@types/leaflet": "^1.9.4", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", diff --git a/packages/api/.env b/packages/api/.env index 6244ed3..1abb8c8 100644 --- a/packages/api/.env +++ b/packages/api/.env @@ -1,6 +1,12 @@ +POSTGRES_PASSWORD=${BM_POSTGRES_PASSWORD:?error} +POSTGRES_USER=${BM_POSTGRES_USER:?error} +POSTGRES_DB=${BM_POSTGRES_DB:?error} + BM_COOKIE_SECRET=${BM_COOKIE_SECRET:?error} -BM_COOKIE_SECURE=true -BM_COOKIE_SAMESITE=strict -BM_SESSION_STORE=redis +BM_COOKIE_SECURE=${BM_COOKIE_SECURE:-true} +BM_COOKIE_SAMESITE=${BM_COOKIE_SAMESITE:-strict} +BM_SESSION_STORE=${BM_SESSION_STORE:-redis} BM_REDIS_HOST=${BM_REDIS_HOST} + +SSO_GOOG_CLIENT_ID=${SSO_GOOG_CLIENT_ID} DEBUG=${DEBUG} diff --git a/packages/api/package.json b/packages/api/package.json index 1c59f38..a206d46 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -30,9 +30,11 @@ "debug": "^2.6.9", "express": "^4.18.2", "express-session": "^1.17.3", + "google-auth-library": "^9.2.0", "helmet": "^7.1.0", "http-errors": "^2.0.0", "morgan": "^1.10.0", + "postgres": "^3.4.3", "redis": "^4.6.10", "restbus": "^2.2.0" }, diff --git a/packages/api/src/db.ts b/packages/api/src/db.ts new file mode 100644 index 0000000..f6a9abf --- /dev/null +++ b/packages/api/src/db.ts @@ -0,0 +1,13 @@ +import { env } from 'node:process' + +import postgres from 'postgres' + +const sql = postgres({ + host: 'db', + port: 5432, + database: env.POSTGRES_DB, + username: env.POSTGRES_USER, + password: env.POSTGRES_PASSWORD +}) + +export { sql } diff --git a/packages/api/src/handlers/authn.ts b/packages/api/src/handlers/authn.ts new file mode 100644 index 0000000..863c4ef --- /dev/null +++ b/packages/api/src/handlers/authn.ts @@ -0,0 +1,106 @@ +import { env } from 'node:process' + +import error from 'http-errors' +import { OAuth2Client } from 'google-auth-library' + +import { sql } from '../db.js' + +import type { Request, Response } from 'express' +import type { TokenPayload } from 'google-auth-library' + +interface User { + sub: string + email: string + given_name: string + family_name: string + full_name: string +} + +const authn = { + async login(req: Request, res: Response) { + if (req.body.credential) { + const client = new OAuth2Client() + let payload: TokenPayload | undefined + + try { + const ticket = await client.verifyIdToken({ + idToken: req.body.credential, + audience: env.SSO_GOOG_CLIENT_ID + }) + + payload = ticket.getPayload() + } catch (err) { + return res.status(403).json(new error.Forbidden()) + } + + if (payload) { + const { sub, email, email_verified } = payload + + if (sub && email && email_verified) { + const { given_name, family_name, name } = payload + + try { + const userRow = await sql` + INSERT INTO rider + (sub, email, sso_provider, given_name, family_name, full_name, last_login) + VALUES + (${sub}, ${email}, 1, ${given_name ?? ''}, ${family_name ?? ''}, ${ + name ?? '' + }, ${new Date().toISOString()}) + ON CONFLICT (sub) DO UPDATE + SET + sub = EXCLUDED.sub, + email = EXCLUDED.email, + sso_provider = EXCLUDED.sso_provider, + given_name = EXCLUDED.given_name, + family_name = EXCLUDED.family_name, + full_name = EXCLUDED.full_name, + last_login = EXCLUDED.last_login + RETURNING + sub, + email, + given_name, + family_name, + full_name + ` + const user = userRow[0] + const sessUser = { + sub: user.sub, + email: user.email, + givenName: user.given_name, + familyName: user.family_name, + fullName: user.full_name + } + + req.session.user = JSON.stringify(sessUser) + + return res.json(sessUser) + } catch (err) { + return res.status(500).json(new error.InternalServerError()) + } + } + } + } + + return res.status(401).json(new error.Unauthorized()) + }, + + status( + req: Request, + res: Response + ): Response<{ isLoggedIn: boolean; user: User | null }> { + if (req.session?.isInitialized && req.session?.user) { + return res.json({ + isLoggedIn: true, + user: JSON.parse(req.session.user) + }) + } + + return res.json({ + isLoggedIn: false, + user: null + }) + } +} + +export { authn } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a6819a2..9b84b10 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -11,6 +11,8 @@ import morgan from 'morgan' import helmet from 'helmet' import restbus from 'restbus' +import { authn } from './routes/authn.js' + import type { SessionOptions, CookieOptions } from 'express-session' const oneDayMs = 86_400_000 @@ -48,14 +50,16 @@ app.set('trust proxy', 1) app.use(env.NODE_ENV === 'production' ? morgan('combined') : morgan('dev')) app.use(helmet()) app.use(session(sess)) -app.use('/', (req, res, next) => { +app.use((req, res, next) => { if (req.session.isInitialized === undefined) { + debug('initializing session', req.sessionID) req.session.isInitialized = true req.session.save(next) } else { next() } }) +app.use('/authn', authn) app.use( '/restbus', (req, res, next) => { diff --git a/packages/api/src/routes/authn.ts b/packages/api/src/routes/authn.ts new file mode 100644 index 0000000..03fb9b9 --- /dev/null +++ b/packages/api/src/routes/authn.ts @@ -0,0 +1,10 @@ +import { Router, json } from 'express' + +import { authn as handler } from '../handlers/authn.js' + +const authn = Router() + +authn.post('/login', json(), handler.login) +authn.get('/status', handler.status) + +export { authn } diff --git a/packages/api/src/session.d.ts b/packages/api/src/session.d.ts index 0f9b070..a72eac0 100644 --- a/packages/api/src/session.d.ts +++ b/packages/api/src/session.d.ts @@ -3,6 +3,17 @@ import type { SessionData } from 'express-session' declare module 'express-session' { interface SessionData { + /** + * Indicates whether the session + * is new or not, i.e the first time + * it has been saved to the store (redis). + */ isInitialized?: boolean + /** + * Whether the session has an authenticated user. + * If so, this is the JSON.stringify version of + * a UI User shape. + */ + user?: string } } diff --git a/packages/components/src/alert/mod.tsx b/packages/components/src/alert/mod.tsx index aa27778..cabec68 100644 --- a/packages/components/src/alert/mod.tsx +++ b/packages/components/src/alert/mod.tsx @@ -15,7 +15,7 @@ interface AlertProps { type AlertRef = HTMLDivElement const AlertBase = styled(MuiAlert)<{ $fullWidth: boolean }>` - &.MuiAlert-standard { + &.MuiAlert-root { width: ${({ $fullWidth }) => ($fullWidth ? '100%' : 'auto')}; .MuiAlert-message { diff --git a/packages/ui/assets/svg/logo-tile.svg b/packages/ui/assets/svg/logo-tile.svg new file mode 100644 index 0000000..98c9a4f --- /dev/null +++ b/packages/ui/assets/svg/logo-tile.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/index.html b/packages/ui/index.html index de3623c..c179981 100644 --- a/packages/ui/index.html +++ b/packages/ui/index.html @@ -33,10 +33,10 @@
+ - diff --git a/packages/ui/package.json b/packages/ui/package.json index 3de6aa1..c748817 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,6 +32,7 @@ "author": "Morgan Ney ", "devDependencies": { "@types/geojson": "^7946.0.10", + "@types/google.accounts": "^0.0.14", "@types/leaflet": "^1.9.4", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", diff --git a/packages/ui/src/api/authn.ts b/packages/ui/src/api/authn.ts new file mode 100644 index 0000000..1afd97b --- /dev/null +++ b/packages/ui/src/api/authn.ts @@ -0,0 +1,21 @@ +import { transport } from './transport.js' + +import type { User } from '@core/types.js' + +const post = async (credential: string) => { + const user = await transport.fetch('/authn/login', { + method: 'POST', + body: JSON.stringify({ credential }) + }) + + return user +} +const get = async () => { + const status = await transport.fetch<{ isLoggedIn: boolean; user: User | null }>( + '/authn/status' + ) + + return status +} + +export { post, get } diff --git a/packages/ui/src/api/transport.ts b/packages/ui/src/api/transport.ts index 5229e92..ba744be 100644 --- a/packages/ui/src/api/transport.ts +++ b/packages/ui/src/api/transport.ts @@ -2,11 +2,27 @@ import { errors } from './errors.js' const defaultInit: RequestInit = { method: 'GET', - credentials: 'include' + credentials: 'include', + headers: new Headers([['Accept', 'application/json']]) } const transport = { async fetch(endpoint: string, options: RequestInit = defaultInit) { const init = { ...defaultInit, ...options } + + if (init.body) { + if (!(init.body instanceof FormData)) { + /** + * Only set Content-Type header with `fetch` when not using `FormData`. + * @see https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sect4 + */ + init.headers = { ...init.headers, 'Content-Type': 'application/json' } + + if (typeof init.body === 'object') { + init.body = JSON.stringify(init.body) + } + } + } + const resp = await fetch(endpoint, init) if (!resp.ok) { diff --git a/packages/ui/src/components/signIn.tsx b/packages/ui/src/components/signIn.tsx index 0c06956..53187f3 100644 --- a/packages/ui/src/components/signIn.tsx +++ b/packages/ui/src/components/signIn.tsx @@ -1,3 +1,6 @@ +import { useEffect, useRef } from 'react' + +import { post, get } from '@core/api/authn.js' import { MAX_FAVORITES } from '@module/favorites/common.js' import { Page } from './page.js' @@ -5,12 +8,45 @@ import { Page } from './page.js' import type { FC } from 'react' const SignIn: FC = () => { + const ref = useRef(null) + + useEffect(() => { + const getStatus = async () => { + // TODO Sign in UX + await get() + } + + getStatus() + }, []) + + useEffect(() => { + if (google && ref.current) { + google.accounts.id.initialize({ + ux_mode: 'popup', + client_id: import.meta.env.VITE_GOOG_CLIENT_ID, + nonce: btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))), + callback: async response => { + await post(response.credential) + + // TODO Sign in UX + } + }) + google.accounts.id.renderButton(ref.current, { + type: 'standard', + click_listener: () => { + // TODO: Sign in UX + } + }) + } + }, []) + return (

Sign in to save your favorite stops and settings across devices. After signing in, you can save more than {MAX_FAVORITES} favorite stops.

+
) } diff --git a/packages/ui/src/globals.tsx b/packages/ui/src/globals.tsx index efad320..5576a45 100644 --- a/packages/ui/src/globals.tsx +++ b/packages/ui/src/globals.tsx @@ -26,6 +26,8 @@ const defaultGlobals: BusmapGlobals = { const Globals = createContext(defaultGlobals) const reducer = (state: BusmapState, action: BusmapAction): BusmapState => { switch (action.type) { + case 'user': + return { ...state, user: action.value } case 'page': return { ...state, page: action.value } case 'collapsed': diff --git a/packages/ui/src/modules/location/components/userLocator.tsx b/packages/ui/src/modules/location/components/userLocator.tsx index 609bb5f..d29c63a 100644 --- a/packages/ui/src/modules/location/components/userLocator.tsx +++ b/packages/ui/src/modules/location/components/userLocator.tsx @@ -50,9 +50,8 @@ const Info = styled.div` ` const AlertWrap = styled.div` display: flex; - justify-content: space-between; align-items: center; - gap: 10px; + gap: 12px; p { margin: 0; @@ -61,18 +60,7 @@ const AlertWrap = styled.div` > div:last-child { line-height: 1; - } -` -const StopButton = styled.button` - ${btn}; - padding: 2px 1px; - font-weight: 600; - color: #014361; - text-align: start; - - &:hover { - background: #014361cc; - color: white; + margin-left: auto; } ` const StreetViewButton = styled.button` @@ -90,14 +78,6 @@ const UserLocator: FC = ({ withDistance = false }) => { ) } }, [position, map]) - const onClickLocateStop = useCallback(() => { - if (map && stop) { - const { lat, lon } = stop - const latLon = latLng(lat, lon) - - map.setView(latLon, Math.max(map.getZoom(), 16)) - } - }, [map, stop]) if (permission !== 'granted') { return null @@ -115,14 +95,18 @@ const UserLocator: FC = ({ withDistance = false }) => { return ( - } fullWidth> + +

- {Intl.NumberFormat(['en-US', 'es-US', 'es-CL'], { - maximumSignificantDigits: sigDig - }).format(distanceInMiles)}{' '} - miles away from{' '} - {stop.title}. + You are{' '} + + {Intl.NumberFormat(['en-US', 'es-US', 'es-CL'], { + maximumSignificantDigits: sigDig + }).format(distanceInMiles)}{' '} + miles + {' '} + away.

diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 4fe5242..fc21c47 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -103,6 +103,13 @@ interface Selection { } // Busmap types +interface User { + sub: string + email: string + fullName: string + givenName: string + familyName: string +} type Page = 'locate' | 'favorites' | 'select' | 'settings' | 'info' | 'signin' interface BoundsChanged { type: 'bounds' @@ -144,7 +151,12 @@ interface CollapsedChanged { type: 'collapsed' value: boolean } +interface UserChanged { + type: 'user' + value: User +} type BusmapAction = + | UserChanged | BoundsChanged | CenterChanged | AgencyChanged @@ -157,6 +169,7 @@ type BusmapAction = | CollapsedChanged interface BusmapGlobals { dispatch: Dispatch + user?: User page: Page collapsed: boolean center: Point @@ -170,6 +183,7 @@ interface BusmapGlobals { } export type { + User, Page, Point, Bounds, diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 9a851ca..e06a6fb 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -5,7 +5,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", - "types": ["vite/client"], + "types": ["vite/client", "google.accounts"], "paths": { "@core/*": ["./src/*"], "@module/*": ["./src/modules/*"] diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 000b03a..17e446f 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -8,12 +8,12 @@ export default defineConfig(() => { const proxy = !env.RESTBUS_HOST ? undefined : { - '/restbus': { - target: env.RESTBUS_HOST - } + '/authn': env.RESTBUS_HOST, + '/restbus': env.RESTBUS_HOST } return { + envDir: resolve('../..'), build: { target: ['chrome118', 'safari16', 'edge118', 'firefox118'], rollupOptions: {