feat: login MAS OIDC end-to-end (issue 0147)

Backend Go:
- MatrixService with Login/GetSession/Logout bindings
- Uses 3 registry helpers via go.work:
  - mas_oidc_loopback_go_infra (PKCE flow)
  - keyring_token_store_go_infra (SO keychain)
  - matrix_client_init_go_infra (mautrix client)
- whoami helper to discover user_id+device_id pre-init

Frontend React+Vite+TS:
- Mantine v7 + @tabler/icons-react
- LoginScreen with 'Sign in with Matrix' button
- HomeScreen with profile card + logout
- Dark theme violet accent
- @mantine/notifications wired

Wails config:
- Switched to pnpm (workspace: protocol)
- Bindings auto-generated for MatrixService
- 1280x800 default window

Build: 68MB linux/amd64 binary in build/bin/
This commit is contained in:
egutierrez
2026-05-24 23:23:35 +02:00
parent 42adfdfd97
commit f28c2b121e
21 changed files with 2053 additions and 238 deletions
+14 -10
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"name": "matrix_client_pc-frontend",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,14 +9,18 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@mantine/notifications": "^7.13.0",
"@tabler/icons-react": "^3.19.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1",
"typescript": "^4.6.4",
"vite": "^3.0.7"
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
}
+1
View File
@@ -0,0 +1 @@
a27c7df4927b39f755bc0add9ca73805
+1445
View File
File diff suppressed because it is too large Load Diff
-59
View File
@@ -1,59 +0,0 @@
#app {
height: 100vh;
text-align: center;
}
#logo {
display: block;
width: 50%;
height: 50%;
margin: auto;
padding: 10% 0 0;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-origin: content-box;
}
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
+41 -23
View File
@@ -1,28 +1,46 @@
import {useState} from 'react';
import logo from './assets/images/logo-universal.png';
import './App.css';
import {Greet} from "../wailsjs/go/main/App";
import { useEffect, useState } from "react";
import { Box, LoadingOverlay } from "@mantine/core";
import LoginScreen from "./LoginScreen";
import HomeScreen from "./HomeScreen";
import { GetSession } from "../wailsjs/go/main/MatrixService";
function App() {
const [resultText, setResultText] = useState("Please enter your name below 👇");
const [name, setName] = useState('');
const updateName = (e: any) => setName(e.target.value);
const updateResultText = (result: string) => setResultText(result);
const LAST_USER_KEY = "matrix_client_pc.last_user_id";
function greet() {
Greet(name).then(updateResultText);
export default function App() {
const [userID, setUserID] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const last = localStorage.getItem(LAST_USER_KEY);
if (!last) {
setLoading(false);
return;
}
GetSession(last)
.then((s) => {
if (s && s.has_token) setUserID(s.user_id);
})
.finally(() => setLoading(false));
}, []);
return (
<div id="App">
<img src={logo} id="logo" alt="logo"/>
<div id="result" className="result">{resultText}</div>
<div id="input" className="input-box">
<input id="name" className="input" onChange={updateName} autoComplete="off" name="input" type="text"/>
<button className="btn" onClick={greet}>Greet</button>
</div>
</div>
)
const handleLogin = (uid: string) => {
localStorage.setItem(LAST_USER_KEY, uid);
setUserID(uid);
};
const handleLogout = () => {
localStorage.removeItem(LAST_USER_KEY);
setUserID(null);
};
return (
<Box pos="relative" mih="100vh">
<LoadingOverlay visible={loading} />
{userID ? (
<HomeScreen userID={userID} onLogout={handleLogout} />
) : (
<LoginScreen onLogin={handleLogin} />
)}
</Box>
);
}
export default App
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import {
AppShell,
Avatar,
Badge,
Box,
Button,
Code,
Group,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconLogout, IconUserCircle } from "@tabler/icons-react";
import { GetSession, Logout } from "../wailsjs/go/main/MatrixService";
interface Session {
user_id: string;
device_id: string;
homeserver_url: string;
has_token: boolean;
expires_at?: string;
}
export default function HomeScreen({
userID,
onLogout,
}: {
userID: string;
onLogout: () => void;
}) {
const [session, setSession] = useState<Session | null>(null);
useEffect(() => {
GetSession(userID).then((s) => setSession(s as Session | null));
}, [userID]);
async function handleLogout() {
try {
await Logout(userID);
} finally {
onLogout();
}
}
const initials = userID
.replace("@", "")
.split(":")[0]
.slice(0, 2)
.toUpperCase();
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group gap="xs">
<IconUserCircle size={22} />
<Text fw={600}>matrix_client_pc</Text>
<Badge size="sm" variant="light" color="violet">
v0.1.0
</Badge>
</Group>
<Button
variant="subtle"
color="gray"
leftSection={<IconLogout size={16} />}
onClick={handleLogout}
>
Logout
</Button>
</Group>
</AppShell.Header>
<AppShell.Main>
<Box maw={720} mx="auto" mt="xl">
<Paper p="xl" radius="lg" withBorder>
<Group gap="lg" align="flex-start">
<Avatar size={64} color="violet" radius="xl">
{initials}
</Avatar>
<Stack gap={6} flex={1}>
<Title order={3}>{userID}</Title>
{session ? (
<Stack gap={4}>
<Text size="sm" c="dimmed">
Device: <Code>{session.device_id || "-"}</Code>
</Text>
<Text size="sm" c="dimmed">
Homeserver: <Code>{session.homeserver_url}</Code>
</Text>
{session.expires_at && (
<Text size="xs" c="dimmed">
Token expira: {session.expires_at}
</Text>
)}
</Stack>
) : (
<Text c="dimmed">Cargando sesion...</Text>
)}
</Stack>
</Group>
</Paper>
<Paper p="md" radius="md" withBorder mt="lg">
<Text size="sm" c="dimmed">
Login OK. Sync + rooms + timeline llegan en issue 0148.
</Text>
</Paper>
</Box>
</AppShell.Main>
</AppShell>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState } from "react";
import {
Button,
Card,
Center,
Stack,
Text,
Title,
Code,
Alert,
} from "@mantine/core";
import { IconBrandMatrix, IconAlertCircle } from "@tabler/icons-react";
import { Login } from "../wailsjs/go/main/MatrixService";
export default function LoginScreen({ onLogin }: { onLogin: (uid: string) => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
setBusy(true);
setError(null);
try {
const uid = await Login();
onLogin(uid);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
return (
<Center mih="100vh" p="lg">
<Card shadow="md" padding="xl" radius="lg" withBorder maw={480} w="100%">
<Stack gap="lg" align="center">
<IconBrandMatrix size={48} color="var(--mantine-color-violet-5)" />
<Stack gap={4} align="center">
<Title order={2}>matrix_client_pc</Title>
<Text size="sm" c="dimmed">
Cliente Matrix nativo PC
</Text>
</Stack>
<Text size="sm" ta="center" c="dimmed" maw={360}>
Inicia sesion en <Code>matrix-af2f3d.organic-machine.com</Code> via{" "}
Matrix Authentication Service. Se abrira tu navegador para autorizar.
</Text>
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
color="red"
variant="light"
w="100%"
>
{error}
</Alert>
)}
<Button
size="md"
color="violet"
loading={busy}
onClick={handleClick}
fullWidth
>
Sign in with Matrix
</Button>
<Text size="xs" c="dimmed">
v0.1.0 (issue 0147)
</Text>
</Stack>
</Card>
</Center>
);
}
+20 -11
View File
@@ -1,14 +1,23 @@
import React from 'react'
import {createRoot} from 'react-dom/client'
import './style.css'
import App from './App'
import React from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider, createTheme } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import App from "./App";
const container = document.getElementById('root')
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
const root = createRoot(container!)
const theme = createTheme({
primaryColor: "violet",
defaultRadius: "md",
fontFamily: "Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
});
root.render(
<React.StrictMode>
<App/>
</React.StrictMode>
)
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<MantineProvider defaultColorScheme="dark" theme={theme}>
<Notifications position="top-right" />
<App />
</MantineProvider>
</React.StrictMode>,
);
-26
View File
@@ -1,26 +0,0 @@
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
}
body {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
}
#app {
height: 100vh;
text-align: center;
}
-4
View File
@@ -1,4 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1: string): Promise<string>;
-7
View File
@@ -1,7 +0,0 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}
+49 -7
View File
@@ -21,8 +21,8 @@ export interface Size {
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width: number
height: number
width : number
height : number
}
// Environment information such as platform, buildtype, ...
@@ -38,19 +38,23 @@ export interface EnvironmentInfo {
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): void;
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): void;
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): void;
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsff)
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string): void;
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
@@ -124,6 +128,10 @@ export function WindowFullscreen(): void;
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
@@ -170,6 +178,10 @@ export function WindowToggleMaximise(): void;
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
@@ -178,6 +190,14 @@ export function WindowMinimise(): void;
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
@@ -205,3 +225,25 @@ export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
+69 -5
View File
@@ -37,19 +37,23 @@ export function LogFatal(message) {
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
EventsOnMultiple(eventName, callback, -1);
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName) {
return window.runtime.EventsOff(eventName);
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
EventsOnMultiple(eventName, callback, 1);
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
@@ -97,6 +101,10 @@ export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
@@ -141,6 +149,10 @@ export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
@@ -157,6 +169,14 @@ export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
@@ -176,3 +196,47 @@ export function Hide() {
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}