OXYGENHUB

Sign in with GitLab, and branded sign-in buttons 810896d41be7

GitLab joins GitHub and Google: gitlab.com, or a self-managed GitLab at
an https address the administrator gives, through GitLab's OpenID
Connect endpoints (openid profile email); the username comes from
nickname, the email only when GitLab marks it verified. The providers
show as buttons beside the sign-in box in Gitter's style: GitLab
red-orange (darkened to 4.8:1 for white text), GitHub dark, Google
white, each with its logo (Simple Icons, CC0), and below the box with
an 'or' on a phone. The registration page offers them when registration
is open. Labels say the provider's name ('Sign in with GitHub'), not its
id.

Closes #213
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-15 15:15 UTC
Signature
✔ signed by Claude Code
Hash
810896d41be780e42f5a7b63a33edcf49b94a106fce7e31212202a5f922b13ac
Depends on
19bb31ff1f5c 34beb1a7e376 83940d53664f addec2bd6423 d23464c0ac60
Depended on by
3ff52534f69b 2a0c09c09716
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
43 atoms, 1 tree operation, 9 files

Browse the tree at this change

added templates/signin_buttons.html +9 −0
1 +
{# Branded sign-in buttons (oxygenhub#213), in the style of Gitter's: the
2 +
   provider's colour and logo (Simple Icons, CC0). Needs `providers` and
3 +
   `verb` ("Sign in" or "Sign up"). #}
4 +
<div class="signin-with">
5 +
{% for (id, name) in providers %}<a class="signin-btn {{ id }}" href="/login/{{ id }}">
6 +
  <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">{% if *id == "gitlab" %}<path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/>{% else if *id == "github" %}<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>{% else if *id == "google" %}<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/>{% endif %}</svg>
7 +
  <span>{{ verb }} with {{ name }}</span>
8 +
</a>
9 +
{% endfor %}</div>
modified crates/hub/src/signin.rs +84 −16
1 1
//! Sign in with an external account (hub spec §5.1.2, oxygenhub#106):
2 -
//! GitHub and Google through OAuth 2.0's authorization code flow, set up
2 +
//! GitLab (gitlab.com or a self-managed GitLab), GitHub and Google
3 +
//! through OAuth 2.0's authorization code flow, set up
3 4
//! by an administrator per provider (client id, a sealed secret, on or
4 5
//! off). A first sign-in links the provider's subject to the account with
5 6
//! the same verified email, or makes an account when registration is
…
12 unchanged lines
18 19
    token: &'static str,
19 20
    userinfo: &'static str,
20 21
    pub scope: &'static str,
22 +
    /// For a provider that can be self-hosted: the address used when the
23 +
    /// administrator gives none. Its endpoints are then paths under the
24 +
    /// address (oxygenhub#213).
25 +
    pub default_base: Option<&'static str>,
21 26
}
22 27
23 -
pub const PROVIDERS: [Provider; 2] = [
28 +
pub const PROVIDERS: [Provider; 3] = [
29 +
    // GitLab's OpenID Connect endpoints, the same on gitlab.com and on a
30 +
    // self-managed GitLab.
24 31
    Provider {
32 +
        id: "gitlab",
33 +
        name: "GitLab",
34 +
        authorize: "/oauth/authorize",
35 +
        token: "/oauth/token",
36 +
        userinfo: "/oauth/userinfo",
37 +
        scope: "openid profile email",
38 +
        default_base: Some("https://gitlab.com"),
39 +
    },
40 +
    Provider {
25 41
        id: "github",
26 42
        name: "GitHub",
27 43
        authorize: "https://github.com/login/oauth/authorize",
28 44
        token: "https://github.com/login/oauth/access_token",
29 45
        userinfo: "https://api.github.com/user",
30 46
        scope: "read:user user:email",
47 +
        default_base: None,
31 48
    },
32 49
    Provider {
33 50
        id: "google",
…
2 unchanged lines
36 53
        token: "https://oauth2.googleapis.com/token",
37 54
        userinfo: "https://openidconnect.googleapis.com/v1/userinfo",
38 55
        scope: "openid email profile",
56 +
        default_base: None,
39 57
    },
40 58
];
41 59
…
4 unchanged lines
46 64
impl Provider {
47 65
    /// The provider's endpoints, or a test double's when `[auth]
48 66
    /// signin_mock` names one: `{mock}/{id}/authorize|token|userinfo`.
49 -
    fn url(&self, app: &Shared, which: &str) -> String {
67 +
    fn url(&self, app: &Shared, s: &Settings, which: &str) -> String {
68 +
        let path = match which {
69 +
            "authorize" => self.authorize,
70 +
            "token" => self.token,
71 +
            _ => self.userinfo,
72 +
        };
50 73
        match &app.config.auth.signin_mock {
51 74
            Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id),
52 -
            None => match which {
53 -
                "authorize" => self.authorize.to_string(),
54 -
                "token" => self.token.to_string(),
55 -
                _ => self.userinfo.to_string(),
75 +
            None => match self.default_base {
76 +
                Some(default) => format!("{}{path}", s.base_or(default)),
77 +
                None => path.to_string(),
56 78
            },
57 79
        }
58 80
    }
…
11 unchanged lines
70 92
    /// The client secret, opened; empty when none is set.
71 93
    pub secret: String,
72 94
    pub enabled: bool,
95 +
    /// A self-hostable provider's address (GitLab), empty for its default.
96 +
    pub base: String,
73 97
}
74 98
75 99
impl Settings {
76 100
    pub fn usable(&self) -> bool {
77 101
        self.enabled && !self.client_id.is_empty() && !self.secret.is_empty()
102 +
    }
103 +
104 +
    /// The address to use: the one set, else `default`.
105 +
    pub fn base_or<'a>(&'a self, default: &'a str) -> &'a str {
106 +
        let b = self.base.trim().trim_end_matches('/');
107 +
        if b.is_empty() {
108 +
            default
109 +
        } else {
110 +
            b
111 +
        }
78 112
    }
79 113
}
80 114
115 +
/// Whether `base` may be a self-hosted provider's address: https, a host,
116 +
/// nothing after it but an optional path prefix, no query.
117 +
pub fn valid_base(base: &str) -> bool {
118 +
    let Some(rest) = base.strip_prefix("https://") else { return false };
119 +
    let host = rest.split('/').next().unwrap_or("");
120 +
    !host.is_empty() && !rest.contains(['?', '#', ' ']) && host.contains('.')
121 +
}
122 +
81 123
fn key(id: &str, what: &str) -> String {
82 124
    format!("signin.{id}.{what}")
83 125
}
…
3 unchanged lines
87 129
    let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default();
88 130
    let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default();
89 131
    let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1");
90 -
    Ok(Settings { client_id, secret, enabled })
132 +
    let base = accounts::setting(&app.db, &key(id, "base")).await?.unwrap_or_default();
133 +
    Ok(Settings { client_id, secret, enabled, base })
91 134
}
92 135
93 136
/// Save a provider's settings; `secret` is `None` to keep the stored one.
94 -
pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool) -> sqlx::Result<()> {
137 +
pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool, base: &str) -> sqlx::Result<()> {
138 +
    accounts::set_setting(&app.db, &key(id, "base"), base.trim().trim_end_matches('/')).await?;
95 139
    accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?;
96 140
    if let Some(s) = secret {
97 141
        accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?;
…
35 unchanged lines
133 177
}
134 178
135 179
/// Where to send the person to sign in with `p`.
136 -
pub fn authorize_url(app: &Shared, p: &Provider, client_id: &str, state: &str) -> String {
180 +
pub fn authorize_url(app: &Shared, p: &Provider, s: &Settings, state: &str) -> String {
137 181
    let q = |s: &str| crate::web::settings::urlencode(s);
138 182
    format!(
139 183
        "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}",
140 -
        p.url(app, "authorize"),
141 -
        q(client_id),
184 +
        p.url(app, s, "authorize"),
185 +
        q(&s.client_id),
142 186
        q(&p.callback_url(app)),
143 187
        q(p.scope),
144 188
        q(state),
…
20 unchanged lines
165 209
        .build()
166 210
        .map_err(|e| e.to_string())?;
167 211
    let token: serde_json::Value = client
168 -
        .post(p.url(app, "token"))
212 +
        .post(p.url(app, s, "token"))
169 213
        .header("accept", "application/json")
170 214
        .form(&[
171 215
            ("client_id", s.client_id.as_str()),
…
28 unchanged lines
200 244
                .map_err(|_| format!("{} answered with something unexpected", p.name))
201 245
        }
202 246
    };
203 -
    let user = get(p.url(app, "userinfo")).await?;
247 +
    let user = get(p.url(app, s, "userinfo")).await?;
204 248
    Ok(match p.id {
205 249
        "github" => {
206 250
            let subject =
207 251
                user["id"].as_i64().map(|i| i.to_string()).or_else(|| user["id"].as_str().map(String::from)).ok_or("GitHub gave no id")?;
208 252
            // The primary verified address is on its own endpoint.
209 -
            let emails = get(format!("{}/emails", p.url(app, "userinfo"))).await.unwrap_or_default();
253 +
            let emails = get(format!("{}/emails", p.url(app, s, "userinfo"))).await.unwrap_or_default();
210 254
            let email = emails
211 255
                .as_array()
212 256
                .and_then(|list| {
…
10 unchanged lines
223 267
                name: user["name"].as_str().unwrap_or("").to_string(),
224 268
                email,
225 269
            }
270 +
        }
271 +
        // GitLab's OpenID Connect userinfo: the subject is its user id, the
272 +
        // username is `nickname`.
273 +
        "gitlab" => {
274 +
            let subject = user["sub"]
275 +
                .as_str()
276 +
                .map(String::from)
277 +
                .or_else(|| user["sub"].as_i64().map(|i| i.to_string()))
278 +
                .ok_or("GitLab gave no subject")?;
279 +
            let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from);
280 +
            let login = user["nickname"].as_str().or(user["preferred_username"].as_str()).unwrap_or("").to_string();
281 +
            External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email }
226 282
        }
227 283
        _ => {
228 284
            let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string();
…
82 unchanged lines
311 367
    fn providers_are_known() {
312 368
        assert_eq!(provider("github").map(|p| p.name), Some("GitHub"));
313 369
        assert_eq!(provider("google").map(|p| p.name), Some("Google"));
314 -
        assert!(provider("gitlab").is_none());
370 +
        assert_eq!(provider("gitlab").map(|p| (p.name, p.default_base)), Some(("GitLab", Some("https://gitlab.com"))));
371 +
        assert!(provider("bitbucket").is_none());
372 +
        // A self-managed GitLab's address (#213).
373 +
        assert!(valid_base("https://gitlab.example.org") && valid_base("https://code.example.org/gitlab"));
374 +
        assert!(
375 +
            !valid_base("http://gitlab.example.org")
376 +
                && !valid_base("https://")
377 +
                && !valid_base("https://gitlab.example.org/?x=1")
378 +
                && !valid_base("https://localhost")
379 +
        );
380 +
        let s = Settings { base: "https://gitlab.example.org/".into(), ..Settings::default() };
381 +
        assert_eq!(s.base_or("https://gitlab.com"), "https://gitlab.example.org");
382 +
        assert_eq!(Settings::default().base_or("https://gitlab.com"), "https://gitlab.com");
315 383
    }
316 384
}
modified crates/hub/src/web/session.rs +14 −4
…
170 unchanged lines
171 171
    mode: String,
172 172
    first: bool,
173 173
    invite: String,
174 +
    /// Providers to sign up with, when registration is open (#213).
175 +
    providers: Vec<(&'static str, &'static str)>,
174 176
}
175 177
176 178
/// The registration mode: the settings table overrides `hub.toml`.
…
12 unchanged lines
189 191
    }
190 192
    let shell = Shell::new(&app, "/register", &viewer);
191 193
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
192 -
    Ok(Page(RegisterPage { shell, error: None, mode: registration_mode(&app).await, first, invite: q.invite.unwrap_or_default() })
193 -
        .into_response())
194 +
    let mode = registration_mode(&app).await;
195 +
    let providers = if mode == "open" && !first { super::signin::buttons(&app).await } else { vec![] };
196 +
    Ok(Page(RegisterPage { shell, error: None, mode, first, invite: q.invite.unwrap_or_default(), providers }).into_response())
194 197
}
195 198
196 199
#[derive(Deserialize)]
…
10 unchanged lines
207 210
    let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0;
208 211
    let mode = registration_mode(&app).await;
209 212
    let fail = |msg: &str| {
210 -
        Page(RegisterPage { shell: shell.clone(), error: Some(msg.into()), mode: mode.clone(), first, invite: f.invite.clone() })
211 -
            .into_response()
213 +
        Page(RegisterPage {
214 +
            shell: shell.clone(),
215 +
            error: Some(msg.into()),
216 +
            mode: mode.clone(),
217 +
            first,
218 +
            invite: f.invite.clone(),
219 +
            providers: vec![],
220 +
        })
221 +
        .into_response()
212 222
    };
213 223
    let username = f.username.trim().to_string();
214 224
    if !crate::repos::names::valid_owner(&username) {
…
49 unchanged lines
modified crates/hub/src/web/signin.rs +17 −2
…
38 unchanged lines
39 39
        return Err(format!("Signing in with {} is not set up on this hub.", p.name));
40 40
    }
41 41
    let state = signin::state_value(app, mode);
42 -
    let to = signin::authorize_url(app, p, &s.client_id, &state);
42 +
    let to = signin::authorize_url(app, p, &s, &state);
43 43
    Ok(([(header::SET_COOKIE, state_cookie(app, &state, 600))], Redirect::to(&to)).into_response())
44 44
}
45 45
…
178 unchanged lines
224 224
    pub has_secret: bool,
225 225
    pub enabled: bool,
226 226
    pub callback: String,
227 +
    /// For a self-hostable provider: its default address and the one set.
228 +
    pub default_base: Option<&'static str>,
229 +
    pub base: String,
227 230
}
228 231
229 232
pub async fn provider_views(app: &Shared) -> sqlx::Result<Vec<ProviderView>> {
…
7 unchanged lines
237 240
            has_secret: !s.secret.is_empty(),
238 241
            enabled: s.enabled,
239 242
            callback: p.callback_url(app),
243 +
            default_base: p.default_base,
244 +
            base: s.base,
240 245
        });
241 246
    }
242 247
    Ok(out)
…
10 unchanged lines
253 258
    pub secret: String,
254 259
    #[serde(default)]
255 260
    pub enabled: Option<String>,
261 +
    /// A self-hostable provider's address (GitLab); empty for its default.
262 +
    #[serde(default)]
263 +
    pub base: String,
256 264
}
257 265
258 266
/// Apply the admin form: `Ok(notice)` or `Err(message)`.
…
6 unchanged lines
265 273
    let secret = f.secret.trim();
266 274
    let secret = if secret.is_empty() { None } else { Some(secret) };
267 275
    let enabled = f.enabled.is_some();
268 -
    signin::save(app, p.id, client_id, secret, enabled).await?;
276 +
    let base = f.base.trim().trim_end_matches('/');
277 +
    if p.default_base.is_none() && !base.is_empty() {
278 +
        return Ok(Err(format!("{} has no address to set.", p.name)));
279 +
    }
280 +
    if !base.is_empty() && !signin::valid_base(base) {
281 +
        return Ok(Err(format!("The {} address is an https:// address, like https://gitlab.example.org.", p.name)));
282 +
    }
283 +
    signin::save(app, p.id, client_id, secret, enabled, base).await?;
269 284
    let s = signin::settings(app, p.id).await?;
270 285
    accounts::audit(&app.db, viewer.account(), "admin.signin", p.id, if s.usable() { "on" } else { "off" }).await.ok();
271 286
    Ok(Ok(if s.usable() {
…
12 unchanged lines
modified crates/hub/tests/accounts.rs +49 −1
…
897 unchanged lines
898 898
            }),
899 899
        )
900 900
        .route(
901 +
            "/gitlab/token",
902 +
            axum::routing::post(|| async { axum::Json(serde_json::json!({ "access_token": "gl-token", "token_type": "Bearer" })) }),
903 +
        )
904 +
        .route(
905 +
            "/gitlab/userinfo",
906 +
            axum::routing::get(|headers: axum::http::HeaderMap| async move {
907 +
                assert_eq!(headers.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer gl-token"));
908 +
                axum::Json(serde_json::json!({ "sub": "1234567", "nickname": "tanuki", "name": "The Tanuki", "email": "[email protected]", "email_verified": true }))
909 +
            }),
910 +
        )
911 +
        .route(
901 912
            "/google/userinfo",
902 913
            axum::routing::get(|| async {
903 914
                axum::Json(serde_json::json!({ "sub": "1076915035000615071", "email": "[email protected]", "email_verified": true, "name": "Andy McCall" }))
…
68 unchanged lines
972 983
    let ok = send_with(app, "GET", &format!("/login/github/callback?code=c2&state={state}"), Some(&cookie), None).await;
973 984
    assert_eq!(ok.location.as_deref(), Some("/"));
974 985
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2);
986 +
987 +
    // GitLab (#213): a self-managed address is checked, then sign-in makes
988 +
    // an account from the GitLab username.
989 +
    let r = post(
990 +
        app,
991 +
        "/admin/signin",
992 +
        Some(&andy),
993 +
        &format!("_csrf={csrf}&provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=http%3A%2F%2Fgitlab.example.org"),
994 +
    )
995 +
    .await;
996 +
    assert!(r.body.contains("is an https:// address"), "{}", r.body);
997 +
    let r = post(
998 +
        app,
999 +
        "/admin/signin",
1000 +
        Some(&andy),
1001 +
        &format!("_csrf={csrf}&provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=https%3A%2F%2Fgitlab.example.org%2F"),
1002 +
    )
1003 +
    .await;
1004 +
    assert!(r.body.contains("Signing in with GitLab is on.") && r.body.contains("value=\"https://gitlab.example.org\""), "{}", r.body);
1005 +
    let r = post(
1006 +
        app,
1007 +
        "/admin/signin",
1008 +
        Some(&andy),
1009 +
        &format!("_csrf={csrf}&provider=github&client_id=gh-cid&enabled=1&base=https%3A%2F%2Fx.org"),
1010 +
    )
1011 +
    .await;
1012 +
    assert!(r.body.contains("GitHub has no address to set."), "{}", r.body);
1013 +
    let login = get(app, "/login", None).await.body;
1014 +
    assert!(login.contains("signin-btn gitlab") && login.contains("Sign in with GitLab"), "{login}");
1015 +
    let r = get(app, "/login/gitlab", None).await;
1016 +
    assert!(r.location.clone().unwrap().contains("scope=openid%20profile%20email"), "{:?}", r.location);
1017 +
    let (cookie, state) = state_of(&r);
1018 +
    let ok = send_with(app, "GET", &format!("/login/gitlab/callback?code=c9&state={state}"), Some(&cookie), None).await;
1019 +
    assert_eq!(ok.location.as_deref(), Some("/"), "{}", ok.body);
1020 +
    let tanuki = db::accounts::account_by_name(&app.db, "tanuki").await.unwrap().expect("account made from the GitLab username");
1021 +
    assert!((tanuki.email.as_str(), tanuki.email_verified) == ("[email protected]", true), "{tanuki:?}");
1022 +
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 3);
975 1023
976 1024
    // Google, for an address Andy has verified: linked to Andy, signed in as Andy.
977 1025
    let r = get(app, "/login/google", None).await;
…
9 unchanged lines
987 1035
            && sec.contains("GitHub: <a href=\"/settings/security/connect/github\">connect</a>"),
988 1036
        "{sec}"
989 1037
    );
990 -
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2, "no third account");
1038 +
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 3, "no fourth account");
991 1039
    // Disconnect, then connect again from Settings › Security.
992 1040
    let csrf_s = csrf_of(&sec);
993 1041
    let r = post(app, "/settings/security/disconnect/google", Some(&as_andy), &format!("_csrf={csrf_s}")).await;
…
99 unchanged lines
modified templates/admin.html +2 −0
…
64 unchanged lines
65 65
    <h3>{{ p.name }} {% if p.enabled && p.has_secret && !p.client_id.is_empty() %}<span class="tag head">on</span>{% else %}<span class="tag dim">off</span>{% endif %}</h3>
66 66
    <label for="cid-{{ p.id }}">Client id</label><input type="text" id="cid-{{ p.id }}" name="client_id" value="{{ p.client_id }}" autocomplete="off">
67 67
    <label for="sec-{{ p.id }}">Client secret</label><input type="password" id="sec-{{ p.id }}" name="secret" placeholder="{% if p.has_secret %}unchanged{% else %}none{% endif %}" autocomplete="new-password">
68 +
    {% if let Some(d) = p.default_base %}<label for="base-{{ p.id }}">{{ p.name }} address</label><input type="url" id="base-{{ p.id }}" name="base" value="{{ p.base }}" placeholder="{{ d }}" autocomplete="off">
69 +
    <p class="small mute">Empty for {{ d }}; or a self-managed {{ p.name }}, like https://gitlab.example.org. Register the hub there as an application with the scopes <code>openid profile email</code>.</p>{% endif %}
68 70
    <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if p.enabled %} checked{% endif %}> on</label></div>
69 71
    <p class="small mute">Callback URL to register with {{ p.name }}: <code>{{ p.callback }}</code></p>
70 72
    <button type="submit">SAVE</button>
…
29 unchanged lines
modified templates/login.html +4 −1
…
6 unchanged lines
7 7
<div class="head"><h1>Sign in <small>{{ shell.site }}</small></h1></div>
8 8
{% if reset %}<p class="note">Your password is set. Sign in with it; every other session was signed out.</p>{% endif %}
9 9
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
10 +
<div class="signin-layout{% if !providers.is_empty() && totp_for.is_none() %} with-providers{% endif %}">
10 11
<form class="card" method="post" action="/login">
11 12
  <input type="hidden" name="next" value="{{ next }}">
12 13
  {% if let Some(u) = totp_for %}
…
7 unchanged lines
20 21
  {% endif %}
21 22
  <button type="submit">SIGN IN</button>
22 23
</form>
23 -
{% if !providers.is_empty() %}<div class="card signin-with"><p class="small mute">Or sign in with</p>{% for (id, name) in providers %}<a class="go" href="/login/{{ id }}">{{ name }}</a> {% endfor %}</div>{% endif %}
24 +
{% if !providers.is_empty() && totp_for.is_none() %}<div class="signin-or" aria-hidden="true"><span>or</span></div>
25 +
{% let verb = "Sign in" %}{% include "signin_buttons.html" %}{% endif %}
26 +
</div>
24 27
<p class="small mute"><a href="/login/reset">Forgot your password?</a> · No account? <a href="/register">Register</a>.</p>
25 28
{% endblock %}
modified templates/register.html +4 −0
…
8 unchanged lines
9 9
{% if !first && mode == "closed" %}
10 10
<p class="empty">Registration is closed. Ask an administrator for an account.</p>
11 11
{% else %}
12 +
<div class="signin-layout">
12 13
<form class="card" method="post" action="/register">
13 14
  <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}">
14 15
  <label for="email">Email</label><input type="email" id="email" name="email" autocomplete="email">
…
1 unchanged lines
16 17
  {% if !first && mode == "invite" %}<label for="invite">Invitation code</label><input type="text" id="invite" name="invite" value="{{ invite }}" required>{% endif %}
17 18
  <button type="submit">CREATE ACCOUNT</button>
18 19
</form>
20 +
{% if !providers.is_empty() %}<div class="signin-or" aria-hidden="true"><span>or</span></div>
21 +
{% let verb = "Sign up" %}{% include "signin_buttons.html" %}{% endif %}
22 +
</div>
19 23
{% endif %}
20 24
{% endblock %}
modified templates/style.css +23 −0
…
1249 unchanged lines
1250 1250
.hash-tip { position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .92em; cursor: help; border-bottom: 1px dotted currentColor; white-space: nowrap; }
1251 1251
.hash-tip::after { content: attr(data-full); position: absolute; z-index: 40; top: calc(100% + 6px); left: 0; width: min(34ch, 80vw); padding: 8px 10px; background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.15); white-space: normal; overflow-wrap: anywhere; word-break: break-all; line-height: 1.45; font-size: 12px; visibility: hidden; opacity: 0; transition: opacity .12s; pointer-events: none; text-transform: none; font-weight: 400; }
1252 1252
.hash-tip:hover::after, .hash-tip:focus::after { visibility: visible; opacity: 1; }
1253 +
/* Sign-in with a provider (oxygenhub#213): branded buttons beside the
1254 +
   sign-in box, in the style of Gitter's (the provider's colour and logo). */
1255 +
.signin-layout { display: flex; flex-wrap: wrap; align-items: center; gap: 24px; }
1256 +
.signin-layout > form.card { flex: 1 1 320px; margin: 0; }
1257 +
.signin-or { display: flex; flex-direction: column; align-items: center; align-self: stretch; color: var(--mute); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
1258 +
.signin-or::before, .signin-or::after { content: ""; flex: 1; width: 1px; background: var(--line); }
1259 +
.signin-or span { padding: 8px 0; }
1260 +
.signin-with { display: flex; flex-direction: column; gap: 10px; flex: 0 1 280px; min-width: 0; }
1261 +
/* `:root .signin-with` outranks the looks' link colours (assets/github.css). */
1262 +
:root .signin-with a.signin-btn { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 11px 16px; border-radius: 4px; color: #fff; font-weight: 700; font-size: 13px; letter-spacing: .06em; text-transform: uppercase; text-decoration: none; border: 1px solid transparent; }
1263 +
:root .signin-with a.signin-btn svg { flex-shrink: 0; fill: currentColor; }
1264 +
:root .signin-with a.signin-btn:hover, :root .signin-with a.signin-btn:focus-visible { filter: brightness(1.08); text-decoration: none; color: #fff; }
1265 +
:root .signin-with a.signin-btn:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
1266 +
:root .signin-with a.signin-btn.gitlab { background: #cf3d1b; } /* GitLab's red-orange, dark enough for white text (4.8:1) */
1267 +
:root .signin-with a.signin-btn.github { background: #24292f; }
1268 +
:root .signin-with a.signin-btn.google { background: #fff; color: #3c4043; border-color: #dadce0; }
1269 +
:root .signin-with a.signin-btn.google:hover, :root .signin-with a.signin-btn.google:focus-visible { color: #3c4043; background: #f8f9fa; filter: none; }
1270 +
@media (max-width: 700px) {
1271 +
  .signin-or { flex-direction: row; width: 100%; }
1272 +
  .signin-or::before, .signin-or::after { height: 1px; width: auto; }
1273 +
  .signin-or span { padding: 0 8px; }
1274 +
  .signin-with { flex-basis: 100%; }
1275 +
}