@@ -2,6 +2,7 @@ import { createSignal } from "solid-js";
22import { clearCache } from "./cache" ;
33import { CONFIG_STORAGE_KEY , resetConfig , updateConfig , config } from "./config" ;
44import { VIEW_STORAGE_KEY , resetViewState } from "./view" ;
5+ import { pushNotification } from "../lib/errors" ;
56
67export const AUTH_STORAGE_KEY = "github-tracker:auth-token" ;
78export const DASHBOARD_STORAGE_KEY = "github-tracker:dashboard" ;
@@ -40,9 +41,13 @@ export { user };
4041// ── Actions ─────────────────────────────────────────────────────────────────
4142
4243export function setAuth ( response : TokenExchangeResponse ) : void {
43- localStorage . setItem ( AUTH_STORAGE_KEY , response . access_token ) ;
44+ try {
45+ localStorage . setItem ( AUTH_STORAGE_KEY , response . access_token ) ;
46+ } catch {
47+ pushNotification ( "localStorage:auth" , "Auth token write failed — storage may be full. Token exists in memory only this session." , "warning" ) ;
48+ }
4449 _setToken ( response . access_token ) ;
45- console . info ( "[auth] access token set (localStorage) " ) ;
50+ console . info ( "[auth] access token set" ) ;
4651}
4752
4853export function setAuthFromPat ( token : string , userData : GitHubUser ) : void {
@@ -89,37 +94,77 @@ export function clearAuth(): void {
8994 }
9095}
9196
97+ /** Clear only the auth token. Preserves all user data (config, view state, dashboard
98+ * cache) so the same user's preferences and cached data survive re-authentication.
99+ * Used when a token becomes invalid (expired PAT, revoked OAuth) — NOT for explicit
100+ * logout. Full data wipe (cross-user data isolation) is handled by clearAuth()
101+ * which is reserved for explicit user actions (Sign out, Reset all).
102+ *
103+ * Callers MUST navigate away after calling this (e.g., window.location.replace or
104+ * router navigate to /login). The poll coordinator is not stopped here — page
105+ * navigation handles teardown. Use clearAuth() if not navigating. */
106+ export function expireToken ( ) : void {
107+ localStorage . removeItem ( AUTH_STORAGE_KEY ) ;
108+ _setToken ( null ) ;
109+ setUser ( null ) ;
110+ console . info ( "[auth] token expired (user data preserved)" ) ;
111+ }
112+
113+ const VALIDATE_HEADERS = {
114+ Accept : "application/vnd.github+json" ,
115+ "X-GitHub-Api-Version" : "2022-11-28" ,
116+ } as const ;
117+
92118export async function validateToken ( ) : Promise < boolean > {
93119 const currentToken = _token ( ) ;
94120 if ( ! currentToken ) return false ;
95121
122+ const headers = { ...VALIDATE_HEADERS , Authorization : `Bearer ${ currentToken } ` } ;
123+
124+ function handleSuccess ( userData : GitHubUser ) : true {
125+ setUser ( { login : userData . login , avatar_url : userData . avatar_url , name : userData . name } ) ;
126+ navigator . storage ?. persist ?.( ) ?. catch ( ( ) => { } ) ;
127+ return true ;
128+ }
129+
96130 try {
97- const resp = await fetch ( "https://api.github.com/user" , {
98- headers : {
99- Authorization : `Bearer ${ currentToken } ` ,
100- Accept : "application/vnd.github+json" ,
101- "X-GitHub-Api-Version" : "2022-11-28" ,
102- } ,
103- } ) ;
131+ const resp = await fetch ( "https://api.github.com/user" , { headers } ) ;
104132
105133 if ( resp . ok ) {
106- const userData = ( await resp . json ( ) ) as GitHubUser ;
107- setUser ( {
108- login : userData . login ,
109- avatar_url : userData . avatar_url ,
110- name : userData . name ,
111- } ) ;
112- return true ;
134+ return handleSuccess ( ( await resp . json ( ) ) as GitHubUser ) ;
113135 }
114136
115137 if ( resp . status === 401 ) {
116- const method = config . authMethod ;
138+ // GitHub API can return transient 401s due to database replication lag.
139+ // Retry once after a delay before invalidating the token.
140+ await new Promise ( ( r ) => setTimeout ( r , 1000 ) ) ;
141+ try {
142+ const retry = await fetch ( "https://api.github.com/user" , { headers } ) ;
143+ if ( retry . ok ) {
144+ return handleSuccess ( ( await retry . json ( ) ) as GitHubUser ) ;
145+ }
146+ if ( retry . status !== 401 ) {
147+ // Non-auth error on retry (e.g. 500) — preserve token, try next load
148+ return false ;
149+ }
150+ } catch {
151+ // Network error on retry — preserve token, try next load
152+ return false ;
153+ }
154+
155+ // Guard: if the token was replaced during the retry window (e.g., user
156+ // re-authenticated via OAuth callback), don't invalidate the new token.
157+ if ( _token ( ) !== currentToken ) {
158+ return false ;
159+ }
160+
161+ // Both attempts returned 401 — token is genuinely invalid
117162 console . info (
118- method === "pat"
119- ? "[auth] PAT invalid or expired — clearing auth "
120- : "[auth] access token invalid — clearing auth "
163+ config . authMethod === "pat"
164+ ? "[auth] PAT invalid or expired — clearing token "
165+ : "[auth] access token invalid — clearing token "
121166 ) ;
122- clearAuth ( ) ;
167+ expireToken ( ) ;
123168 return false ;
124169 }
125170
0 commit comments