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
+}
+
+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
- {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}