Skip to content

Commit 24d2469

Browse files
feat(auth): add permission upgrade UI and missing service icons
- Add clickable account cards with pointer cursor - Add "Permissions" button on hover to trigger re-auth - Add /auth/upgrade endpoint with force-consent and login_hint - Add service icons for classroom, groups, docs, keep - Show all services on success page with missing ones greyed out Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a1c394e commit 24d2469

4 files changed

Lines changed: 186 additions & 9 deletions

File tree

internal/googleauth/accounts_server.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ func StartManageServer(ctx context.Context, opts ManageServerOptions) error {
108108
mux.HandleFunc("/", ms.handleAccountsPage)
109109
mux.HandleFunc("/accounts", ms.handleListAccounts)
110110
mux.HandleFunc("/auth/start", ms.handleAuthStart)
111+
mux.HandleFunc("/auth/upgrade", ms.handleAuthUpgrade)
111112
mux.HandleFunc("/oauth2/callback", ms.handleOAuthCallback)
112113
mux.HandleFunc("/set-default", ms.handleSetDefault)
113114
mux.HandleFunc("/remove-account", ms.handleRemoveAccount)
@@ -245,6 +246,56 @@ func (ms *ManageServer) handleAuthStart(w http.ResponseWriter, r *http.Request)
245246
http.Redirect(w, r, authURL, http.StatusFound)
246247
}
247248

249+
func (ms *ManageServer) handleAuthUpgrade(w http.ResponseWriter, r *http.Request) {
250+
// Similar to handleAuthStart, but always forces consent to get new scopes
251+
email := r.URL.Query().Get("email")
252+
if email == "" {
253+
http.Error(w, "Missing email parameter", http.StatusBadRequest)
254+
return
255+
}
256+
257+
creds, err := readClientCredentials()
258+
if err != nil {
259+
http.Error(w, "OAuth credentials not configured. Run: gog auth credentials <file>", http.StatusInternalServerError)
260+
return
261+
}
262+
263+
state, err := randomStateFn()
264+
if err != nil {
265+
http.Error(w, "Failed to generate state", http.StatusInternalServerError)
266+
return
267+
}
268+
ms.oauthState = state
269+
270+
// Always use all services for upgrade
271+
services := AllServices()
272+
273+
scopes, err := ScopesForManage(services)
274+
if err != nil {
275+
http.Error(w, "Failed to get scopes", http.StatusInternalServerError)
276+
return
277+
}
278+
279+
port := ms.listener.Addr().(*net.TCPAddr).Port
280+
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oauth2/callback", port)
281+
282+
cfg := oauth2.Config{
283+
ClientID: creds.ClientID,
284+
ClientSecret: creds.ClientSecret,
285+
Endpoint: oauthEndpoint,
286+
RedirectURL: redirectURI,
287+
Scopes: scopes,
288+
}
289+
290+
// Always force consent for upgrades to ensure user sees all scopes
291+
// Add login_hint to pre-select the account
292+
authURL := cfg.AuthCodeURL(state,
293+
append(authURLParams(true),
294+
oauth2.SetAuthURLParam("login_hint", email))...)
295+
296+
http.Redirect(w, r, authURL, http.StatusFound)
297+
}
298+
248299
func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
249300
q := r.URL.Query()
250301

@@ -566,9 +617,17 @@ func renderSuccessPageWithDetails(w http.ResponseWriter, email string, services
566617
_, _ = w.Write([]byte("Success! You can close this window."))
567618
return
568619
}
620+
621+
// Get all available services for showing connected vs missing
622+
allServices := make([]string, 0, len(serviceOrder))
623+
for _, svc := range serviceOrder {
624+
allServices = append(allServices, string(svc))
625+
}
626+
569627
data := successTemplateData{
570628
Email: email,
571629
Services: services,
630+
AllServices: allServices,
572631
CountdownSeconds: postSuccessDisplaySeconds,
573632
}
574633
_ = tmpl.Execute(w, data)

internal/googleauth/oauth_flow.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const postSuccessDisplaySeconds = 30
3838
type successTemplateData struct {
3939
Email string
4040
Services []string
41+
AllServices []string
4142
CountdownSeconds int
4243
}
4344

internal/googleauth/templates/accounts.html

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,34 @@
303303
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%234285F4' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M22 5.18L10.59 16.6l-4.24-4.24 1.41-1.41 2.83 2.83 10-10L22 5.18zm-2.21 5.04c.13.57.21 1.17.21 1.78 0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8c1.58 0 3.04.46 4.28 1.25l1.44-1.44A9.9 9.9 0 0012 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10c0-1.19-.22-2.33-.6-3.39l-1.61 1.61z'/%3E%3C/svg%3E");
304304
}
305305

306+
.service-tag.classroom {
307+
color: var(--g-green);
308+
background-color: var(--g-green-dim);
309+
border-color: rgba(52, 168, 83, 0.2);
310+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%2334A853' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3L1 9l4 2.18v6L12 21l7-3.82v-6l2-1.09V17h2V9L12 3zm6.82 6L12 12.72 5.18 9 12 5.28 18.82 9zM17 15.99l-5 2.73-5-2.73v-3.72L12 15l5-2.73v3.72z'/%3E%3C/svg%3E");
311+
}
312+
313+
.service-tag.groups {
314+
color: var(--g-blue);
315+
background-color: var(--g-blue-dim);
316+
border-color: rgba(66, 133, 244, 0.2);
317+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%234285F4' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z'/%3E%3C/svg%3E");
318+
}
319+
320+
.service-tag.docs {
321+
color: var(--g-blue);
322+
background-color: var(--g-blue-dim);
323+
border-color: rgba(66, 133, 244, 0.2);
324+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%234285F4' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.727 0v6h6l-6-6zm1.364 10.636H7.91v1.91h8.181v-1.91zm0 3.273H7.91v1.91h8.181v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zM16.091 7.727H7.91v1.91h8.181v-1.91z'/%3E%3C/svg%3E");
325+
}
326+
327+
.service-tag.keep {
328+
color: var(--g-yellow);
329+
background-color: var(--g-yellow-dim);
330+
border-color: rgba(251, 188, 5, 0.2);
331+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%23FBBC05' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 2C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2 15H10v-1h4v1zm0-2H10v-1h4v1zm-1.5-4.59V13h-1v-2.59L9.67 8.59 10.41 7.85 12 9.44l1.59-1.59.74.74-1.83 1.82z'/%3E%3C/svg%3E");
332+
}
333+
306334
.account-actions {
307335
display: flex;
308336
align-items: center;
@@ -382,6 +410,37 @@
382410
height: 16px;
383411
}
384412

413+
.upgrade-btn {
414+
font-size: 11px;
415+
font-weight: 500;
416+
color: var(--g-green);
417+
background: var(--g-green-dim);
418+
border: 1px solid rgba(52, 168, 83, 0.3);
419+
padding: 5px 10px;
420+
border-radius: 99px;
421+
cursor: pointer;
422+
transition: all 0.2s ease;
423+
opacity: 0;
424+
font-family: inherit;
425+
display: inline-flex;
426+
align-items: center;
427+
gap: 4px;
428+
}
429+
430+
.account-card:hover .upgrade-btn {
431+
opacity: 1;
432+
}
433+
434+
.upgrade-btn:hover {
435+
background: rgba(52, 168, 83, 0.2);
436+
border-color: var(--g-green);
437+
}
438+
439+
.upgrade-btn svg {
440+
width: 12px;
441+
height: 12px;
442+
}
443+
385444
/* Add Account Button */
386445
.add-account-btn {
387446
width: 100%;
@@ -754,7 +813,7 @@ <h4>Use from terminal</h4>
754813
const services = acc.services || ['calendar', 'gmail', 'drive'];
755814

756815
return `
757-
<div class="account-card ${isDefault ? 'default' : ''}" data-email="${acc.email}">
816+
<div class="account-card ${isDefault ? 'default' : ''}" data-email="${acc.email}" onclick="upgradePermissions('${acc.email}', event)">
758817
<div class="account-avatar" style="background: ${getAvatarGradient(acc.email)}">
759818
${initial}
760819
</div>
@@ -765,6 +824,13 @@ <h4>Use from terminal</h4>
765824
</div>
766825
</div>
767826
<div class="account-actions">
827+
<button class="upgrade-btn" onclick="upgradePermissions('${acc.email}', event)" title="Add more permissions">
828+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
829+
<line x1="12" y1="5" x2="12" y2="19"/>
830+
<line x1="5" y1="12" x2="19" y2="12"/>
831+
</svg>
832+
Permissions
833+
</button>
768834
${isDefault ? `
769835
<span class="default-badge">
770836
<svg viewBox="0 0 24 24" fill="currentColor">
@@ -773,9 +839,9 @@ <h4>Use from terminal</h4>
773839
Default
774840
</span>
775841
` : `
776-
<button class="set-default-btn" onclick="setDefault('${acc.email}')">Set default</button>
842+
<button class="set-default-btn" onclick="event.stopPropagation(); setDefault('${acc.email}')">Set default</button>
777843
`}
778-
<button class="remove-btn" onclick="removeAccount('${acc.email}')" title="Remove account">
844+
<button class="remove-btn" onclick="event.stopPropagation(); removeAccount('${acc.email}')" title="Remove account">
779845
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
780846
<line x1="18" y1="6" x2="6" y2="18"/>
781847
<line x1="6" y1="6" x2="18" y2="18"/>
@@ -793,6 +859,12 @@ <h4>Use from terminal</h4>
793859
window.location.href = '/auth/start';
794860
}
795861

862+
function upgradePermissions(email, event) {
863+
event.stopPropagation();
864+
// Redirect to OAuth flow with force consent to get all permissions
865+
window.location.href = '/auth/upgrade?email=' + encodeURIComponent(email);
866+
}
867+
796868
async function setDefault(email) {
797869
try {
798870
const response = await fetch('/set-default', {

internal/googleauth/templates/success.html

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,41 @@
252252
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%234285F4' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M22 5.18L10.59 16.6l-4.24-4.24 1.41-1.41 2.83 2.83 10-10L22 5.18zm-2.21 5.04c.13.57.21 1.17.21 1.78 0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8c1.58 0 3.04.46 4.28 1.25l1.44-1.44A9.9 9.9 0 0012 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10c0-1.19-.22-2.33-.6-3.39l-1.61 1.61z'/%3E%3C/svg%3E");
253253
}
254254

255+
.service-tag.classroom {
256+
color: var(--g-green);
257+
background-color: var(--g-green-dim);
258+
border-color: rgba(52, 168, 83, 0.2);
259+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%2334A853' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3L1 9l4 2.18v6L12 21l7-3.82v-6l2-1.09V17h2V9L12 3zm6.82 6L12 12.72 5.18 9 12 5.28 18.82 9zM17 15.99l-5 2.73-5-2.73v-3.72L12 15l5-2.73v3.72z'/%3E%3C/svg%3E");
260+
}
261+
262+
.service-tag.groups {
263+
color: var(--g-blue);
264+
background-color: var(--g-blue-dim);
265+
border-color: rgba(66, 133, 244, 0.2);
266+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%234285F4' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z'/%3E%3C/svg%3E");
267+
}
268+
269+
.service-tag.docs {
270+
color: var(--g-blue);
271+
background-color: var(--g-blue-dim);
272+
border-color: rgba(66, 133, 244, 0.2);
273+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%234285F4' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.727 0v6h6l-6-6zm1.364 10.636H7.91v1.91h8.181v-1.91zm0 3.273H7.91v1.91h8.181v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zM16.091 7.727H7.91v1.91h8.181v-1.91z'/%3E%3C/svg%3E");
274+
}
275+
276+
.service-tag.keep {
277+
color: var(--g-yellow);
278+
background-color: var(--g-yellow-dim);
279+
border-color: rgba(251, 188, 5, 0.2);
280+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%23FBBC05' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 2C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2 15H10v-1h4v1zm0-2H10v-1h4v1zm-1.5-4.59V13h-1v-2.59L9.67 8.59 10.41 7.85 12 9.44l1.59-1.59.74.74-1.83 1.82z'/%3E%3C/svg%3E");
281+
}
282+
283+
/* Missing/greyed-out service state */
284+
.service-tag.missing {
285+
opacity: 0.4;
286+
filter: grayscale(100%);
287+
border-color: rgba(255, 255, 255, 0.1) !important;
288+
}
289+
255290
/* Terminal Card */
256291
.terminal-card {
257292
background: var(--bg-surface);
@@ -557,13 +592,9 @@ <h1>You're connected</h1>
557592
<p class="account-email">gog is now authorized to access <strong>Google Workspace</strong></p>
558593
{{end}}
559594

560-
{{if .Services}}
561-
<div class="services-row">
562-
{{range .Services}}
563-
<span class="service-tag {{.}}">{{.}}</span>
564-
{{end}}
595+
<div class="services-row" id="servicesRow">
596+
<!-- Services will be rendered by JavaScript to show connected vs missing -->
565597
</div>
566-
{{end}}
567598
</header>
568599

569600
<div class="terminal-card">
@@ -642,6 +673,20 @@ <h4>Return to your terminal</h4>
642673
</footer>
643674
</div>
644675
<script>
676+
// Render services with connected ones colored and missing ones greyed out
677+
const connectedServices = [{{range $i, $s := .Services}}{{if $i}},{{end}}"{{$s}}"{{end}}];
678+
const allServices = [{{range $i, $s := .AllServices}}{{if $i}},{{end}}"{{$s}}"{{end}}];
679+
const servicesRow = document.getElementById('servicesRow');
680+
681+
if (allServices.length > 0) {
682+
servicesRow.innerHTML = allServices.map(svc => {
683+
const isConnected = connectedServices.includes(svc);
684+
const classes = isConnected ? `service-tag ${svc}` : `service-tag ${svc} missing`;
685+
return `<span class="${classes}">${svc}</span>`;
686+
}).join('');
687+
}
688+
689+
// Countdown timer
645690
let seconds = {{.CountdownSeconds}};
646691
const countdownEl = document.getElementById('countdown');
647692
const interval = setInterval(() => {

0 commit comments

Comments
 (0)