diff --git a/pkg/gomuks/media.go b/pkg/gomuks/media.go index c948a76..149455a 100644 --- a/pkg/gomuks/media.go +++ b/pkg/gomuks/media.go @@ -22,6 +22,7 @@ import ( "encoding/hex" "errors" "fmt" + "html" "image" _ "image/gif" _ "image/jpeg" @@ -38,7 +39,6 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" _ "golang.org/x/image/webp" - "golang.org/x/net/html" "go.mau.fi/util/exhttp" "go.mau.fi/util/ffmpeg" diff --git a/pkg/gomuks/server.go b/pkg/gomuks/server.go index 7411b4a..abbd2e4 100644 --- a/pkg/gomuks/server.go +++ b/pkg/gomuks/server.go @@ -46,6 +46,8 @@ func (gmx *Gomuks) StartServer() { api.HandleFunc("GET /websocket", gmx.HandleWebsocket) api.HandleFunc("POST /auth", gmx.Authenticate) api.HandleFunc("POST /upload", gmx.UploadMedia) + api.HandleFunc("GET /sso", gmx.HandleSSOComplete) + api.HandleFunc("POST /sso", gmx.PrepareSSO) api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia) api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS) apiHandler := exhttp.ApplyMiddleware( @@ -111,8 +113,8 @@ type tokenData struct { ImageOnly bool `json:"image_only,omitempty"` } -func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool { - if len(token) > 500 { +func (gmx *Gomuks) validateToken(token string, output any) bool { + if len(token) > 4096 { return false } parts := strings.Split(token, ".") @@ -133,9 +135,19 @@ func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool { return false } + err = json.Unmarshal(rawJSON, output) + return err == nil +} + +func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool { + if len(token) > 500 { + return false + } var td tokenData - err = json.Unmarshal(rawJSON, &td) - return err == nil && td.Username == gmx.Config.Web.Username && td.Expiry.After(time.Now()) && td.ImageOnly == imageOnly + return gmx.validateToken(token, &td) && + td.Username == gmx.Config.Web.Username && + td.Expiry.After(time.Now()) && + td.ImageOnly == imageOnly } func (gmx *Gomuks) generateToken() (string, time.Time) { @@ -154,7 +166,7 @@ func (gmx *Gomuks) generateImageToken() string { }) } -func (gmx *Gomuks) signToken(td tokenData) string { +func (gmx *Gomuks) signToken(td any) string { data := exerrors.Must(json.Marshal(td)) hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey)) hasher.Write(data) @@ -202,10 +214,7 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) { } func isUserFetch(header http.Header) bool { - return (header.Get("Sec-Fetch-Site") == "none" || - header.Get("Sec-Fetch-Site") == "same-site" || - header.Get("Sec-Fetch-Site") == "same-origin") && - header.Get("Sec-Fetch-Mode") == "navigate" && + return header.Get("Sec-Fetch-Mode") == "navigate" && header.Get("Sec-Fetch-Dest") == "document" && header.Get("Sec-Fetch-User") == "?1" } diff --git a/pkg/gomuks/sso.go b/pkg/gomuks/sso.go new file mode 100644 index 0000000..c1f736a --- /dev/null +++ b/pkg/gomuks/sso.go @@ -0,0 +1,128 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gomuks + +import ( + "encoding/json" + "fmt" + "html" + "net/http" + "net/url" + "time" + + "go.mau.fi/util/random" + "maunium.net/go/mautrix" +) + +const ssoErrorPage = ` + + + + + gomuks web + + + +

Failed to log in

+

%s

+ +` + +func (gmx *Gomuks) parseSSOServerURL(r *http.Request) error { + cookie, _ := r.Cookie("gomuks_sso_session") + if cookie == nil { + return fmt.Errorf("no SSO session cookie") + } + var cookieData SSOCookieData + if !gmx.validateToken(cookie.Value, &cookieData) { + return fmt.Errorf("invalid SSO session cookie") + } else if cookieData.SessionID != r.URL.Query().Get("gomuksSession") { + return fmt.Errorf("session ID mismatch in query param and cookie") + } else if time.Until(cookieData.Expiry) < 0 { + return fmt.Errorf("SSO session cookie expired") + } + var err error + gmx.Client.Client.HomeserverURL, err = url.Parse(cookieData.HomeserverURL) + if err != nil { + return fmt.Errorf("failed to parse server URL: %w", err) + } + return nil +} + +func (gmx *Gomuks) HandleSSOComplete(w http.ResponseWriter, r *http.Request) { + err := gmx.parseSSOServerURL(r) + if err != nil { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error())) + return + } + err = gmx.Client.Login(r.Context(), &mautrix.ReqLogin{ + Type: mautrix.AuthTypeToken, + Token: r.URL.Query().Get("loginToken"), + }) + if err != nil { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error())) + } else { + w.Header().Set("Location", "..") + w.WriteHeader(http.StatusFound) + } +} + +type SSOCookieData struct { + SessionID string `json:"session_id"` + HomeserverURL string `json:"homeserver_url"` + Expiry time.Time `json:"expiry"` +} + +func (gmx *Gomuks) PrepareSSO(w http.ResponseWriter, r *http.Request) { + var data SSOCookieData + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + mautrix.MBadJSON.WithMessage("Failed to decode request JSON").Write(w) + return + } + data.SessionID = random.String(16) + data.Expiry = time.Now().Add(30 * time.Minute) + cookieData, err := json.Marshal(&data) + if err != nil { + mautrix.MUnknown.WithMessage("Failed to encode cookie data").Write(w) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "gomuks_sso_session", + Value: gmx.signToken(json.RawMessage(cookieData)), + Expires: data.Expiry, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(cookieData) +} diff --git a/pkg/hicli/hicli.go b/pkg/hicli/hicli.go index 062ee05..b53d282 100644 --- a/pkg/hicli/hicli.go +++ b/pkg/hicli/hicli.go @@ -121,6 +121,19 @@ func New(rawDB, cryptoDB *dbutil.Database, log zerolog.Logger, pickleKey []byte, return c } +func (h *HiClient) tempClient(homeserverURL string) (*mautrix.Client, error) { + parsedURL, err := url.Parse(homeserverURL) + if err != nil { + return nil, err + } + return &mautrix.Client{ + HomeserverURL: parsedURL, + UserAgent: h.Client.UserAgent, + Client: h.Client.Client, + Log: h.Log.With().Str("component", "temp mautrix client").Logger(), + }, nil +} + func (h *HiClient) IsLoggedIn() bool { return h.Account != nil } @@ -191,7 +204,11 @@ var ErrOutdatedServer = errors.New("homeserver is outdated") var MinimumSpecVersion = mautrix.SpecV11 func (h *HiClient) CheckServerVersions(ctx context.Context) error { - versions, err := h.Client.Versions(ctx) + return h.checkServerVersions(ctx, h.Client) +} + +func (h *HiClient) checkServerVersions(ctx context.Context, cli *mautrix.Client) error { + versions, err := cli.Versions(ctx) if err != nil { return exerrors.NewDualError(ErrFailedToCheckServerVersions, err) } else if !versions.Contains(MinimumSpecVersion) { diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 860c4cc..c141eda 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "time" "maunium.net/go/mautrix" @@ -117,6 +118,15 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *loginParams) (bool, error) { return true, h.LoginPassword(ctx, params.HomeserverURL, params.Username, params.Password) }) + case "login_custom": + return unmarshalAndCall(req.Data, func(params *loginCustomParams) (bool, error) { + var err error + h.Client.HomeserverURL, err = url.Parse(params.HomeserverURL) + if err != nil { + return false, err + } + return true, h.Login(ctx, params.Request) + }) case "verify": return unmarshalAndCall(req.Data, func(params *verifyParams) (bool, error) { return true, h.VerifyWithRecoveryKey(ctx, params.RecoveryKey) @@ -129,6 +139,18 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any } return mautrix.DiscoverClientAPI(ctx, homeserver) }) + case "get_login_flows": + return unmarshalAndCall(req.Data, func(params *getLoginFlowsParams) (*mautrix.RespLoginFlows, error) { + cli, err := h.tempClient(params.HomeserverURL) + if err != nil { + return nil, err + } + err = h.checkServerVersions(ctx, cli) + if err != nil { + return nil, err + } + return cli.GetLoginFlows(ctx) + }) default: return nil, fmt.Errorf("unknown command %q", req.Command) } @@ -236,6 +258,11 @@ type loginParams struct { Password string `json:"password"` } +type loginCustomParams struct { + HomeserverURL string `json:"homeserver_url"` + Request *mautrix.ReqLogin `json:"request"` +} + type verifyParams struct { RecoveryKey string `json:"recovery_key"` } @@ -244,6 +271,10 @@ type discoverHomeserverParams struct { UserID id.UserID `json:"user_id"` } +type getLoginFlowsParams struct { + HomeserverURL string `json:"homeserver_url"` +} + type paginateParams struct { RoomID id.RoomID `json:"room_id"` MaxTimelineID database.TimelineRowID `json:"max_timeline_id"` diff --git a/pkg/hicli/login.go b/pkg/hicli/login.go index 06ad693..dd1e5fa 100644 --- a/pkg/hicli/login.go +++ b/pkg/hicli/login.go @@ -31,8 +31,7 @@ func (h *HiClient) LoginPassword(ctx context.Context, homeserverURL, username, p Type: mautrix.IdentifierTypeUser, User: username, }, - Password: password, - InitialDeviceDisplayName: InitialDeviceDisplayName, + Password: password, }) } @@ -41,6 +40,7 @@ func (h *HiClient) Login(ctx context.Context, req *mautrix.ReqLogin) error { if err != nil { return err } + req.InitialDeviceDisplayName = InitialDeviceDisplayName req.StoreCredentials = true req.StoreHomeserverURL = true resp, err := h.Client.Login(ctx, req) diff --git a/web/src/api/beeper.ts b/web/src/api/beeper.ts new file mode 100644 index 0000000..b65a2c6 --- /dev/null +++ b/web/src/api/beeper.ts @@ -0,0 +1,73 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const headers = { + "Authorization": "Bearer BEEPER-PRIVATE-API-PLEASE-DONT-USE", + "Content-Type": "application/json", +} + +async function tryJSON(resp: Response): Promise { + try { + return await resp.json() + } catch (err) { + console.error(err) + } +} + +export async function doSubmitCode(domain: string, request: string, response: string): Promise { + const resp = await fetch(`https://api.${domain}/user/login/response`, { + method: "POST", + body: JSON.stringify({ response, request }), + headers, + }) + const data = await tryJSON(resp) as { token?: string, error?: string } + console.log("Login code submit response data:", data) + if (!resp.ok) { + throw new Error(data ? `HTTP ${resp.status} / ${data?.error ?? JSON.stringify(data)}` : `HTTP ${resp.status}`) + } else if (!data || typeof data !== "object" || typeof data.token !== "string") { + throw new Error(`No token returned`) + } + return data.token +} + +export async function doRequestCode(domain: string, request: string, email: string) { + const resp = await fetch(`https://api.${domain}/user/login/email`, { + method: "POST", + body: JSON.stringify({ email, request }), + headers, + }) + const data = await tryJSON(resp) as { error?: string } + console.log("Login email submit response data:", data) + if (!resp.ok) { + throw new Error(data ? `HTTP ${resp.status} / ${data?.error ?? JSON.stringify(data)}` : `HTTP ${resp.status}`) + } +} + +export async function doStartLogin(domain: string): Promise { + const resp = await fetch(`https://api.${domain}/user/login`, { + method: "POST", + body: "{}", + headers, + }) + const data = await tryJSON(resp) as { request?: string, error?: string } + console.log("Login start response data:", data) + if (!resp.ok) { + throw new Error(data ? `HTTP ${resp.status} / ${data?.error ?? JSON.stringify(data)}` : `HTTP ${resp.status}`) + } else if (!data || typeof data !== "object" || typeof data.request !== "string") { + throw new Error(`No request ID returned`) + } + return data.request +} diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 4f0a8ea..38277c9 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -20,6 +20,8 @@ import type { EventID, EventRowID, EventType, + LoginFlowsResponse, + LoginRequest, Mentions, MessageEventContent, PaginationResponse, @@ -202,10 +204,18 @@ export default abstract class RPCClient { return this.request("discover_homeserver", { user_id }) } + getLoginFlows(homeserver_url: string): Promise { + return this.request("get_login_flows", { homeserver_url }) + } + login(homeserver_url: string, username: string, password: string): Promise { return this.request("login", { homeserver_url, username, password }) } + loginCustom(homeserver_url: string, request: LoginRequest): Promise { + return this.request("login_custom", { homeserver_url, request }) + } + verify(recovery_key: string): Promise { return this.request("verify", { recovery_key }) } diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 1bce825..edd0c64 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -153,6 +153,12 @@ export interface ResolveAliasResponse { servers: string[] } +export interface LoginFlowsResponse { + flows: { + type: string + }[] +} + export interface EventUnsigned { prev_content?: unknown prev_sender?: UserID @@ -191,3 +197,24 @@ export interface RoomStateGUID { type: EventType state_key: string } + +export interface PasswordLoginRequest { + type: "m.login.password" + identifier: { + type: "m.id.user" + user: string + } + password: string +} + +export interface SSOLoginRequest { + type: "m.login.token" + token: string +} + +export interface JWTLoginRequest { + type: "org.matrix.login.jwt" + token: string +} + +export type LoginRequest = PasswordLoginRequest | SSOLoginRequest | JWTLoginRequest diff --git a/web/src/index.css b/web/src/index.css index 982978b..77ab9a2 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -165,13 +165,14 @@ button, a.button, span.button { align-items: center; color: inherit; + &:hover, &:focus { + background-color: var(--button-hover-color); + } + &:disabled { cursor: default; color: var(--secondary-text-color); - } - - &:hover:not(:disabled), &:focus:not(:disabled) { - background-color: var(--button-hover-color); + background: none; } } diff --git a/web/src/ui/login/BeeperLogin.tsx b/web/src/ui/login/BeeperLogin.tsx new file mode 100644 index 0000000..2b97e8a --- /dev/null +++ b/web/src/ui/login/BeeperLogin.tsx @@ -0,0 +1,94 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import React, { useCallback, useState } from "react" +import * as beeper from "@/api/beeper.ts" +import type Client from "@/api/client.ts" +import useEvent from "@/util/useEvent.ts" + +interface BeeperLoginProps { + domain: string + client: Client +} + +const BeeperLogin = ({ domain, client }: BeeperLoginProps) => { + const [email, setEmail] = useState("") + const [requestID, setRequestID] = useState("") + const [code, setCode] = useState("") + const [error, setError] = useState("") + + const onChangeEmail = useCallback((evt: React.ChangeEvent) => { + setEmail(evt.target.value) + }, []) + const onChangeCode = useCallback((evt: React.ChangeEvent) => { + let codeDigits = evt.target.value.replace(/\D/g, "").slice(0, 6) + if (codeDigits.length > 3) { + codeDigits = codeDigits.slice(0, 3) + " " + codeDigits.slice(3) + } + setCode(codeDigits) + }, []) + + const requestCode = useEvent((evt: React.FormEvent) => { + evt.preventDefault() + beeper.doStartLogin(domain).then( + request => beeper.doRequestCode(domain, request, email).then( + () => setRequestID(request), + err => setError(`Failed to request code: ${err}`), + ), + err => setError(`Failed to start login: ${err}`), + ) + }) + const submitCode = useEvent((evt: React.FormEvent) => { + evt.preventDefault() + beeper.doSubmitCode(domain, requestID, code).then( + token => { + client.rpc.loginCustom(`https://matrix.${domain}`, { + type: "org.matrix.login.jwt", + token, + }).catch(err => setError(`Failed to login with token: ${err}`)) + }, + err => setError(`Failed to submit code: ${err}`), + ) + }) + + return
+

Beeper email login

+ + {requestID && } + + {error &&
+ {error} +
} +
+} + +export default BeeperLogin diff --git a/web/src/ui/login/LoginScreen.css b/web/src/ui/login/LoginScreen.css index 4a9cb6e..56ec324 100644 --- a/web/src/ui/login/LoginScreen.css +++ b/web/src/ui/login/LoginScreen.css @@ -18,9 +18,16 @@ main.matrix-login { h1 { margin: 0 0 2rem; + } + h1, h2, h3 { text-align: center; } + div.buttons { + display: flex; + gap: .5rem; + } + button, input { margin-top: .5rem; padding: 1rem; @@ -61,5 +68,6 @@ main.matrix-login { border: 2px solid var(--error-color); border-radius: .25rem; padding: 1rem; + margin-top: .5rem; } } diff --git a/web/src/ui/login/LoginScreen.tsx b/web/src/ui/login/LoginScreen.tsx index 0428c58..cbf9e95 100644 --- a/web/src/ui/login/LoginScreen.tsx +++ b/web/src/ui/login/LoginScreen.tsx @@ -14,8 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React, { useCallback, useEffect, useState } from "react" -import type Client from "../../api/client.ts" -import { ClientState } from "../../api/types" +import type Client from "@/api/client.ts" +import type { ClientState } from "@/api/types" +import useEvent from "@/util/useEvent.ts" +import BeeperLogin from "./BeeperLogin.tsx" import "./LoginScreen.css" export interface LoginScreenProps { @@ -23,26 +25,65 @@ export interface LoginScreenProps { clientState: ClientState } +const beeperServerRegex = /^https:\/\/matrix\.(beeper(?:-dev|-staging)?\.com)$/ + export const LoginScreen = ({ client }: LoginScreenProps) => { const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [homeserverURL, setHomeserverURL] = useState("") + const [loginFlows, setLoginFlows] = useState(null) const [error, setError] = useState("") - const login = useCallback((evt: React.FormEvent) => { + const loginSSO = useEvent(() => { + fetch("_gomuks/sso", { + method: "POST", + body: JSON.stringify({ homeserver_url: homeserverURL }), + headers: { "Content-Type": "application/json" }, + }).then(resp => resp.json()).then( + resp => { + const redirectURL = new URL(window.location.href) + if (!redirectURL.pathname.endsWith("/")) { + redirectURL.pathname += "/" + } + redirectURL.pathname += "_gomuks/sso" + redirectURL.search = `?gomuksSession=${resp.session_id}` + redirectURL.hash = "" + const redir = encodeURIComponent(redirectURL.toString()) + window.location.href = `${homeserverURL}/_matrix/client/v3/login/sso/redirect?redirectUrl=${redir}` + }, + err => setError(`Failed to start SSO login: ${err}`), + ) + }) + + const login = useEvent((evt: React.FormEvent) => { evt.preventDefault() + if (!loginFlows?.includes("m.login.password")) { + loginSSO() + return + } client.rpc.login(homeserverURL, username, password).then( () => {}, err => setError(err.toString()), ) - }, [homeserverURL, username, password, client]) + }) + const resolveLoginFlows = useCallback((serverURL: string) => { + client.rpc.getLoginFlows(serverURL).then( + resp => setLoginFlows(resp.flows.map(flow => flow.type)), + err => setError(`Failed to get login flows: ${err}`), + ) + }, [client]) const resolveHomeserver = useCallback(() => { client.rpc.discoverHomeserver(username).then( - resp => setHomeserverURL(resp["m.homeserver"].base_url), + resp => { + const url = resp["m.homeserver"].base_url + setLoginFlows(null) + setHomeserverURL(url) + resolveLoginFlows(url) + }, err => setError(`Failed to resolve homeserver: ${err}`), ) - }, [client, username]) + }, [client, username, resolveLoginFlows]) useEffect(() => { if (!username.startsWith("@") || !username.includes(":") || !username.includes(".")) { @@ -53,7 +94,20 @@ export const LoginScreen = ({ client }: LoginScreenProps) => { clearTimeout(timeout) } }, [username, resolveHomeserver]) + const onChangeUsername = useCallback((evt: React.ChangeEvent) => { + setUsername(evt.target.value) + }, []) + const onChangePassword = useCallback((evt: React.ChangeEvent) => { + setPassword(evt.target.value) + }, []) + const onChangeHomeserverURL = useCallback((evt: React.ChangeEvent) => { + setLoginFlows(null) + setHomeserverURL(evt.target.value) + }, []) + const supportsSSO = loginFlows?.includes("m.login.sso") ?? false + const supportsPassword = loginFlows?.includes("m.login.password") + const beeperDomain = homeserverURL.match(beeperServerRegex)?.[1] return

gomuks web

@@ -62,26 +116,41 @@ export const LoginScreen = ({ client }: LoginScreenProps) => { id="mxlogin-username" placeholder="User ID" value={username} - onChange={evt => setUsername(evt.target.value)} + onChange={onChangeUsername} /> - setPassword(evt.target.value)} - /> + onChange={onChangePassword} + />} setHomeserverURL(evt.target.value)} + onChange={onChangeHomeserverURL} /> - +
+ {supportsSSO && } + {supportsPassword !== false && } +
+ {error &&
+ {error} +
}
- {error &&
- {error} -
} + + {beeperDomain && <> +
+ + }
} diff --git a/web/src/ui/login/VerificationScreen.tsx b/web/src/ui/login/VerificationScreen.tsx index 350ddc9..1ad1b1e 100644 --- a/web/src/ui/login/VerificationScreen.tsx +++ b/web/src/ui/login/VerificationScreen.tsx @@ -38,6 +38,7 @@ export const VerificationScreen = ({ client, clientState }: LoginScreenProps) =>

Successfully logged in as {clientState.user_id}