Conversation
❌ Deploy Preview for gr-sentinel failed. Why did it fail? →
|
❌ Deploy Preview for gr-sentinel failed. Why did it fail? →
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Alumni went to UCSB so they have a real graduate level to select. UCSB email users (no role) are current students by definition. Only Mentors, Sponsors, and Other roles get the N/A option and the contextual note.
Adds the 5 narrowly-scoped POSTs the discord consume flow will fan out to: POST /core/entity POST /core/users POST /core/entity/:id/email-auth POST /core/entity/:id/phone-auth POST /core/entity/:id/external-auth email-auth bcrypt-hashes via existing service.HashPassword and validates with ValidatePassword. external-auth gets a new service.CreateExternalAuthForEntity helper (read paths existed; create did not). User gains an InitialRole field (member|alumni|mentor|sponsor |other) so we can record what role the user picked from the consent dialog.
Discord: POST /discord/onboarding-tokens/:id/consume validates the token, enforces the initial_role enum and the past-grad-year + UCSB email rule, then orchestrates the 5 core endpoints in sequence (entity -> user -> email-auth -> phone-auth -> external-auth) and finally marks the token used_at + entity_id linked. Best-effort: a mid-flight failure leaves an orphan Entity for a future cleanup job. Web: replace the mocked setTimeout submit with an actual axios POST, toast errors from the response, then run the convergence animation and redirect to /auth/login?email=<email> on success. Adds the academic- step grad-year gate (toast on Continue when grad_year is in the past + email is @ucsb.edu). initial_role is derived from nonStudentRole + the same domain-match guard the academic note uses: UCSB email or no role pick -> "member".
The credentials-step "Are you a current student?" dialog rendered softly the first time it opened and crisp on subsequent opens. Caused by the browser deferring compositor-layer promotion until after the first zoom-in-95 animation frame; sub-pixel positions get rendered without anti-alias-friendly layering and look blurred. transform-gpu hints translateZ(0) onto the dialog content from initial mount, so the layer exists before the animation starts. Same fix is broadly recommended for backdrop-filter / animated translate stacks.
The credentials-step dialog rendered blurry on first open because the overlay's backdrop-filter was leaking into the sibling content during the initial compositor-layer setup. transform-gpu didn't help — the real fix is to not run the filter at all. Aligns the overlay with shadcn's default treatment: bg-black/50, no backdrop-filter. Content keeps our customizations (bg-popover, ring border, p-4, sm:max-w-sm). The transform-gpu hint is no longer needed.
Several read paths used 'var x []T' and let gorm Find populate, which
leaves the slice nil when no rows match. Go marshals nil slices as null
instead of [], forcing every consumer to defensive-coalesce. Replace
with 'x := []T{}' across user, group, application, entity_login.
Also marks Entity.User and Entity.ServiceAccount as omitempty so the
entity JSON is no longer cluttered with the irrelevant nil sibling.
POST /api/auth/login/email-password (oauth service)
POST /api/auth/refresh (oauth service)
Both return { access_token, refresh_token, expires_in, entity_id }
scoped to the Sentinel app (client_id "sentinel", scope
"user:read user:write groups:read applications:read"). Mirror of OAuth
token responses but without the OAuth dance — these are for first-party
Sentinel clients (the web app), not third-party integrators.
Login flow: oauth POSTs core's new internal /core/login/email-password
which wraps service.LoginEmailPassword; on success oauth builds claims
and mints the pair using the same generateToken helper that backs
authorization_code exchange. Refresh validates the JWT, revokes the
old refresh token, and mints a fresh pair. Both record an entity_login
for audit.
Kerbecs gets a new /api/auth/* route pointing at oauth, and oauth
registers /auth/** as a second route prefix with Rincon.
Replaces the LoginPage mock submit with an actual POST to
/api/auth/login/email-password, stores the resulting access/refresh
pair in localStorage via a new lib/auth.ts helper, and runs the
existing convergence + checkmark transition before navigating to /.
When the user arrives from onboarding via /auth/login?email=...,
the email field is prefilled and the header copy switches to
"Account created — sign in with the password you just set."
Errors from the endpoint (401 invalid credentials, 400 etc.) surface
as sonner toasts; the user stays on the form. Google/Discord provider
buttons still mock for now — those plug into /auth/login/{google,discord}
when the OAuth provider integrations land.
RequireAuth wraps the AppShell route subtree. Without a stored session it Navigate-redirects to /auth/login while preserving the intended location in state.from so LoginPage can route the user back after sign-in. Bare /, /applications, /groups, etc. now bounce to login. api.ts gains two interceptors: - request: attaches Authorization: Bearer <accessToken> from the stored session when one exists. - response: on 401, calls /auth/refresh once (coalesced across concurrent in-flight requests via a shared promise), saves the new pair, and retries the original request. If refresh itself fails it clears the session and force-navigates to /auth/login. The refresh request URL is exempt so we don't loop. LoginPage reads state.from?.pathname (set by RequireAuth) and routes back there on success; defaults to / when arriving fresh or from onboarding (?email=...).
…n header
Adds TanStack Query as the data layer. main.tsx wraps the app in a
QueryClientProvider with retry: 1 and refetchOnWindowFocus: false.
lib/auth.ts grows from storage helpers to also export an Entity type
(mirrors core's response shape) and a useAuth() hook that:
- reads the local session synchronously
- queries GET /core/entity/<entityId> via useQuery, keyed by entityId,
5min staleTime, only enabled when a session exists
- exposes { session, user, isLoading, isAuthenticated, refresh, logout }
logout() clears the session, drops the query cache, and force-navigates
to /auth/login. refresh() invalidates the currentEntity query so
mutations (e.g. settings edits, future) can call it and have all
consumers re-render.
AppHeader is the first consumer: drops mockUser and renders a
Skeleton avatar while loading, then the real name / email / avatar.
Sign-out wired to logout().
Adds the two public entity endpoints that frontends and authenticated third-party clients should use, replacing direct hits to /core/entity/:id (which is now strictly service-to-service). Both handlers gate via the existing AuthChecker + Require() idiom: - GetMe (/entities/@me) requires user:read scope; resolves the entity from the bearer's subject claim. - GetEntity (/entities/:id) requires user:read AND the URL :id must match the bearer's entity_id. Self-only until shared-group / admin scope authz lands. Two new accessors in api.go bottom: GetRequestTokenEntityID, RequestTokenHasEntityID, RequestTokenExists. Rincon route prefix /entities/** registered for core, kerbecs gets /api/entities/* upstream. Web's useAuth swaps from GET /core/entity/:id to GET /entities/@me — no longer needs to know its own entity_id at call time (the bearer is the identity).
…ntities/:id Third-party apps still gated on user:read scope and matching entity_id; first-party tokens (audience sentinel) skip the id check so team directory / cross-entity reads from the Sentinel web app work without a dedicated admin scope. When a real cross-user scope (eg users:read) is needed for non-sentinel callers, add it to the Any() chain.
Reads the first_name from the live entity (via useAuth) instead of mockUser, with a skeleton placeholder while the query is in flight. Recently-accessed and recent-activity sections still mock — they need a per-user logins endpoint (we dropped /entities/@me/logins earlier) and a way to fetch app metadata for each distinct client_id in the user's history.
Cloudflare sets CF-Connecting-IP server-side (unspoofable as long as the origin only accepts traffic from CF's IP ranges) so we can rely on it for the real client IP without configuring trusted proxies in gin. Falls back to c.ClientIP() in dev where no CF is in front. Helper lives at the bottom of oauth/api/api.go; all three entity-login recording sites (login, refresh, OAuth code exchange) swap over.
Public endpoint exposing the user's session history. Filters via query params: client_id match one application's logins (eg sentinel, blix) scope exact-match scope string before RFC3339 cutoff, returns rows created before this time after RFC3339 cutoff, returns rows created after this time limit integer cap, default unlimited service.GetEntityLogins refactored to take an EntityLoginsFilter struct so the parameter list doesn't grow unbounded; existing /core/entity/:entityID/logins caller updated. Authz follows the /entities/:id pattern: aud=sentinel (first-party app) bypasses self-only; third-party tokens need user:read scope AND must match the bearer's user_id claim. Two new helpers in api.go bottom: GetRequestTokenUserID, RequestTokenHasUserID.
Replaces mockRecentLogins with a useQuery against
GET /users/{user_id}/logins?limit=5, keyed off the user id from
useAuth(). Skeleton rows while in flight, empty-state copy when there
are no logins, then real rows showing the client_id, scope, ip_address
and created_at.
Recently-accessed apps still mocked — needs per-app-metadata fanout
(dedupe client_ids in logins + bulk fetch /applications/client/:id),
which is a separate piece of work.
After logins resolve, dedupe client_ids and fire one GET /applications/client/<cid> per unique value (cached 5min). Display becomes 'client_id · App Name' with the name in muted text when the fetch succeeds; falls back to just client_id otherwise. Uses useQueries so all the per-app fetches happen in parallel and share the React Query cache with anywhere else we might fetch the same app.
…tly Accessed
Per-user 'apps signed into, ordered by most-recent-access' aggregated
server-side via DISTINCT-ON-equivalent (GROUP BY client_id with MAX
created_at) joined to the application table. Single query returns
denormalized {Application, last_accessed_at} so the frontend skips the
client-side dedupe + per-app fanout that would have missed apps with
lopsided login distributions (50 sentinel logins drowning out one Blix
login last week).
service.GetAccessedApplicationsForEntity(entityID, limit)
returns []AccessedApplication{model.Application, last_accessed_at}
GET /api/users/:id/applications?limit=N
same authz as /users/:id/logins (aud=sentinel bypass OR
user:read + matching user_id)
HomePage swaps mockApplications for a useQuery against the new endpoint.
Skeleton tiles while loading, friendly empty copy when there are no
logins yet, real AppCard grid otherwise.
…tions Disambiguates from a future /users/:id/applications endpoint that would list apps the user owns (registered as OAuth clients). The frontend useQuery key, handler name, and route all rename in lockstep.
Distinct from redirect_uris (OAuth callbacks) — this is where to send
the user to actually open the app, used by the dashboard's Recently
Accessed tiles and anywhere else we surface a clickable app.
model.Application gains LaunchURL string
init job sets the Sentinel app's launch_url to
https://sso.gauchoracing.com on first create
HomePage AccessedApplication response type adds launch_url, maps to
the Application.url field the AppCard renders
Existing rows: AutoMigrate adds the column nullable; backfill the
Sentinel row in dev manually if needed. When other apps are registered
they should set launch_url at create time.
Previously rsa.GenerateKey was called in InitializeKeys every time core
booted, so every air-triggered rebuild invalidated every active session.
Users were getting logged out on every code change.
Adds model.SigningKey with Active boolean. InitializeKeys now:
- loads the active key from signing_key table if one exists
- otherwise generates a fresh keypair, PEM-encodes it, persists with
Active=true, and uses it
The Active flag is unused today (always one active key) but is the
schema we need for rotation: mint a new active key, mark the old one
inactive, expose both public halves via JWKS until old tokens age out.
That work adds a kid header to JWTs and per-kid lookup in ValidateToken
but doesn't require a migration.
Sessions now survive core restarts cleanly.
Replaces the 'Coming soon' stub on /applications with a real listing backed by GET /applications. Search input filters client-side across name/description/client_id; results sort alphabetically. Loading skeletons, empty-state copy for both 'no apps registered' and 'no matches'. Pulls AppCard out of HomePage into components/AppCard.tsx so both pages share it. The shared card accepts the API Application shape directly (snake_case fields, launch_url for the link, icon_url with fallback to the gradient + initial). Optional lastAccessedAt prop renders the 'Last accessed Xh ago' footer only on the dashboard's Recently Accessed section. New lib/applications.ts holds the Application TS type mirroring the core JSON shape — same pattern as lib/auth.ts's Entity type.
Clicking a tile (dashboard Recently Accessed or full Applications grid)
now opens /applications/:id rather than launching the external app in a
new tab. The details page is where the user can see the app's metadata
and explicitly launch from there.
components/AppCard becomes a <Link to=...>, chevron icon on hover
pages/applications/ApplicationDetailsPage.tsx
- useQuery GET /applications/:id
- skeleton while loading, "not found" on miss
- header with icon, name, description, Launch outline button
- rows: client_id, launch URL, redirect URIs, registered date
…d vs browse behavior
Two click affordances are different concerns:
- Dashboard (Recently Accessed) — user wants to jump back into the app
they were using. LaunchAppCard opens launch_url in a new tab with
the external-link icon as the hover affordance.
- Applications page — user wants to inspect details (client_id,
redirect URIs, last-launched, etc.) before launching. AppCard
navigates to /applications/:id with a chevron-right hover icon.
Leaves room for both to diverge as we add more variant-specific UI
(launch counts on the dashboard card, ownership badges on the browse
card, etc.) without one component swelling into a config soup.
Backend
Splits CreateOrUpdateApplication into:
POST /applications — create, response includes client_secret once
PUT /applications/:id — update name/description/icon_url/launch_url
Create handler builds a createdApplicationResponse that embeds
model.Application and adds a separate Secret field (json:client_secret)
since model.Application JSON-skips client_secret on subsequent reads.
Web
/applications/new ApplicationNewPage — form, on success shows
client_id + client_secret in a dialog with
copy-to-clipboard buttons before routing to
the new app's details page
/applications/:id/edit ApplicationEditPage — fetches existing, prefills
form, PUTs the edits, invalidates the index +
details queries
/applications gains "New application" CTA in the header
/applications/:id gains "Edit" button next to "Launch"
Redirect URI management is still TODO — we have add/remove endpoints
on the backend but no UI for them on the edit page yet.
Adds GET /applications/:id/secret (gated on aud=sentinel — first-party only) so the secret can be retrieved on demand without leaking through every app read. model.Application keeps json:- on ClientSecret so list and by-id reads stay secretless. Details page gains a Client Secret row with masked dots, an eye toggle that triggers the secret fetch (useQuery enabled on toggle, 5min cache), and a copy button. Client ID row gains a copy button to match. Authz tightens later when we have application ownership — at that point the gate becomes ownership OR sentinel:all instead of aud=sentinel.
Details page now reads as three cards instead of one bordered row stack: - OAuth credentials (Client ID + masked secret with reveal/copy) - Redirect URIs (list with copy buttons, link to edit when empty) - Metadata (Launch URL, Registered, Last updated) Header gets a wrap-friendly layout so the action buttons reflow on narrow screens. Edit page becomes two cards: - Basic info (name, description, launch_url, icon_url + save) - Redirect URIs (list with X-to-remove, inline form to add) Redirect changes fire POST/DELETE /applications/:id/redirect-uris and invalidate the by-id query so both edit and details refresh. Adds a CopyableMono helper for the mono-font value + copy-button rows that appear in three places now (client_id, secret, each redirect URI).
Backend CreateApplication now Requires a bearer and sets OwnerID from GetRequestTokenEntityID(c). The init job sets the bootstrap Sentinel app's OwnerID to the Sentinel core entity so it isn't orphaned. Web Application TS type already had owner_id; Entity type gains the service_account variant (mirror of model.Entity.ServiceAccount, also omitempty server-side). Details page Metadata card adds a "Created by" row that fetches GET /entities/<owner_id> via useQuery (5min cache) and renders the user's full name, or the service account name when the owner is a service entity. Existing rows in dev: backfill the Sentinel app manually with UPDATE application SET owner_id = '<sentinel_core_entity_id>' WHERE id = '<sentinel_app_id>' AND owner_id = ''
Reads (GET /applications, /applications/:id, /applications/:id/groups,
/applications/:id/redirect-uris):
Require aud=sentinel OR applications:read scope
Writes (POST /applications, PUT /:id, DELETE /:id, group + redirect-uri
mutations):
ApplicationWriteAuthorized helper:
sentinel:all
OR aud=sentinel AND bearer's entity_id == app.owner_id
OR applications:write scope AND bearer's entity_id == app.owner_id
Loaded application is fetched before the gate runs so the owner
comparison has data.
Secrets (GET /applications/:id/secret):
Stricter than reads — sentinel:all OR aud=sentinel + owner only.
A third-party app with applications:read can't get any app's secret.
Create (POST /applications):
aud=sentinel OR applications:write. No owner check (no app yet).
GET /applications/client/:clientID stays ungated — oauth's authorize
flow hits it over the docker network with no bearer. When we add
service-to-service client_credentials tokens, this can be locked down
behind sentinel:all.
ApplicationWriteAuthorized helper added at the bottom of application.go.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sentinel v5 rewrite, see design doc here for more info.