@@ -105,11 +105,15 @@ dashboard.get('/auth/github/callback', async (c) => {
105105 // Optional org membership check
106106 const allowedOrg = process . env . ALLOWED_GITHUB_ORG ;
107107 if ( allowedOrg ) {
108+ // Prefer a server-side PAT (already SSO-authorized) so the user's OAuth token
109+ // doesn't need read:org or SAML SSO authorization.
110+ const checkToken = process . env . GITHUB_ORG_CHECK_TOKEN || accessToken ;
108111 try {
109112 const memberRes = await fetch ( `https://api.github.com/orgs/${ allowedOrg } /members/${ userData . login } ` , {
110113 headers : {
111- Authorization : `Bearer ${ accessToken } ` ,
114+ Authorization : `Bearer ${ checkToken } ` ,
112115 'User-Agent' : 'copilot-sharing-server/1.0' ,
116+ Accept : 'application/vnd.github+json' ,
113117 } ,
114118 signal : AbortSignal . timeout ( 10_000 ) ,
115119 } ) ;
@@ -142,6 +146,80 @@ dashboard.get('/auth/logout', (c) => {
142146 return c . redirect ( '/dashboard' ) ;
143147} ) ;
144148
149+ /** POST /auth/pat — Sign in with a GitHub Personal Access Token. */
150+ dashboard . post ( '/auth/pat' , async ( c ) => {
151+ const body = await c . req . parseBody ( ) ;
152+ const token = ( body [ 'pat' ] as string ?? '' ) . trim ( ) ;
153+
154+ if ( ! token ) {
155+ return c . html ( errorPage ( 'No token provided.' ) , 400 ) ;
156+ }
157+
158+ // Fetch the authenticated user
159+ let userData : { id : number ; login : string ; name : string | null ; avatar_url : string } ;
160+ try {
161+ const userRes = await fetch ( 'https://api.github.com/user' , {
162+ headers : {
163+ Authorization : `Bearer ${ token } ` ,
164+ 'User-Agent' : 'copilot-sharing-server/1.0' ,
165+ Accept : 'application/vnd.github+json' ,
166+ } ,
167+ signal : AbortSignal . timeout ( 10_000 ) ,
168+ } ) ;
169+ if ( ! userRes . ok ) {
170+ return c . html ( errorPage ( 'Invalid token or unable to verify GitHub identity.' ) , 401 ) ;
171+ }
172+ userData = await userRes . json ( ) as typeof userData ;
173+ } catch ( err ) {
174+ return c . html ( errorPage ( `Failed to reach GitHub: ${ String ( err ) } ` ) , 502 ) ;
175+ }
176+
177+ // Optional org membership check
178+ const allowedOrg = process . env . ALLOWED_GITHUB_ORG ;
179+ if ( allowedOrg ) {
180+ try {
181+ const memberRes = await fetch ( `https://api.github.com/user/memberships/orgs/${ allowedOrg } ` , {
182+ headers : {
183+ Authorization : `Bearer ${ token } ` ,
184+ 'User-Agent' : 'copilot-sharing-server/1.0' ,
185+ Accept : 'application/vnd.github+json' ,
186+ } ,
187+ signal : AbortSignal . timeout ( 10_000 ) ,
188+ } ) ;
189+ if ( memberRes . status === 403 ) {
190+ return c . html ( errorPage (
191+ `Access denied: your token is not authorized for the "${ allowedOrg } " organization. ` +
192+ `If this org uses SAML SSO, go to github.com → Settings → Personal access tokens, ` +
193+ `click your token, and grant SSO access to the "${ allowedOrg } " org.`
194+ ) , 403 ) ;
195+ }
196+ if ( memberRes . status !== 200 ) {
197+ return c . html ( errorPage ( `Access denied: you are not a member of the "${ allowedOrg } " organization.` ) , 403 ) ;
198+ }
199+ const membership = await memberRes . json ( ) as { state : string } ;
200+ if ( membership . state !== 'active' ) {
201+ return c . html ( errorPage ( `Access denied: your membership in the "${ allowedOrg } " organization is not active.` ) , 403 ) ;
202+ }
203+ } catch {
204+ return c . html ( errorPage ( 'Unable to verify organization membership. Please try again.' ) , 502 ) ;
205+ }
206+ }
207+
208+ const user = upsertUser ( userData . id , userData . login , userData . name , userData . avatar_url ) ;
209+ const claims = makeClaims ( user . id ) ;
210+ const sessionValue = encodeSession ( claims ) ;
211+
212+ setCookie ( c , COOKIE_NAME , sessionValue , {
213+ httpOnly : true ,
214+ secure : process . env . NODE_ENV === 'production' ,
215+ sameSite : 'Lax' ,
216+ maxAge : SESSION_MAX_AGE ,
217+ path : '/' ,
218+ } ) ;
219+
220+ return c . redirect ( '/dashboard' ) ;
221+ } ) ;
222+
145223// ── Dashboard ─────────────────────────────────────────────────────────────────
146224
147225/** Redirect root to dashboard. */
@@ -676,14 +754,42 @@ ${body}
676754}
677755
678756function loginPage ( ) : string {
757+ const oauthAvailable = ! ! process . env . GITHUB_CLIENT_ID ;
758+ const oauthSection = oauthAvailable ? `
759+ <a href="/auth/github" class="btn btn-primary" style="font-size:1rem; padding:12px 28px">
760+ Sign in with GitHub (OAuth)
761+ </a>
762+ <p style="color:#8b949e; margin-top:8px; font-size:0.85rem">
763+ Note: requires org admin approval for SSO organizations.
764+ </p>
765+ <div style="color:#8b949e; margin: 24px 0; font-size:0.9rem">— or —</div>` : '' ;
766+
679767 return layout ( 'Sign In' , `
680768<div class="header"><h1>🤖 Copilot Token Tracker Sharing</h1></div>
681769<div class="content" style="text-align:center; margin-top: 80px; align-items:center">
682770 <h2 style="color:#e6edf3">Sign in to view your usage dashboard</h2>
683771 <p style="color:#8b949e">Your data is linked to your GitHub account. No account creation needed.</p>
684- <a href="/auth/github" class="btn btn-primary" style="font-size:1rem; padding:12px 28px">
685- Sign in with GitHub
686- </a>
772+ ${ oauthSection }
773+ <form method="POST" action="/auth/pat" style="max-width:420px; margin:0 auto; text-align:left">
774+ <label style="color:#8b949e; font-size:0.9rem; display:block; margin-bottom:6px">
775+ Sign in with a GitHub Personal Access Token (PAT)
776+ </label>
777+ <input
778+ type="password"
779+ name="pat"
780+ placeholder="ghp_..."
781+ required
782+ autocomplete="off"
783+ style="width:100%; padding:10px 12px; border-radius:6px; border:1px solid #30363d;
784+ background:#161b22; color:#e6edf3; font-size:0.95rem; box-sizing:border-box"
785+ />
786+ <p style="color:#8b949e; font-size:0.8rem; margin:6px 0 12px">
787+ Needs <code>read:user</code> scope (and <code>read:org</code> + SSO authorization if your org enforces SAML SSO).
788+ </p>
789+ <button type="submit" class="btn btn-primary" style="width:100%; font-size:1rem; padding:10px">
790+ Sign in with PAT
791+ </button>
792+ </form>
687793 <p style="color:#8b949e; margin-top:32px; font-size:0.85rem">
688794 The VS Code extension uploads data automatically using your existing GitHub session —
689795 no separate sign-in required.
@@ -1269,6 +1375,30 @@ var CHART_DATA = ${safeJson(chartData)};
12691375</script>
12701376<script>${ _chartJsCode } </script>
12711377<script>${ interactiveJs } </script>
1378+ <script>
1379+ // Re-compute "Today" stats using browser's local timezone (server pre-renders in UTC)
1380+ (function() {
1381+ function fmtLocal(n) {
1382+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1383+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1384+ return String(n);
1385+ }
1386+ var todayLocal = new Date().toLocaleDateString('sv-SE'); // YYYY-MM-DD in local timezone
1387+ var todayData = CHART_DATA.filter(function(r) { return r.day === todayLocal; });
1388+ var inputTokens = todayData.reduce(function(s, r) { return s + r.inputTokens; }, 0);
1389+ var outputTokens = todayData.reduce(function(s, r) { return s + r.outputTokens; }, 0);
1390+ var interactions = todayData.reduce(function(s, r) { return s + r.interactions; }, 0);
1391+ var daysActive = todayData.length > 0 ? 1 : 0;
1392+ var panel = document.getElementById('stats-today');
1393+ if (panel) {
1394+ var values = panel.querySelectorAll('.stat-card .value');
1395+ if (values[0]) values[0].textContent = fmtLocal(inputTokens);
1396+ if (values[1]) values[1].textContent = fmtLocal(outputTokens);
1397+ if (values[2]) values[2].textContent = fmtLocal(interactions);
1398+ if (values[3]) values[3].textContent = String(daysActive);
1399+ }
1400+ })();
1401+ </script>
12721402<script>${ fluencyJs } </script>` ) ;
12731403}
12741404
0 commit comments