From e60f8028aaaa064ed4699d7e9d0ce99c244721d7 Mon Sep 17 00:00:00 2001 From: Izaak Kuipers Date: Sat, 8 Aug 2026 02:13:57 +0200 Subject: [PATCH 1/3] Login screen v10: multi-user is back! --- src/apps/core/newloginapp/NewLogin.svelte | 56 ++ .../core/newloginapp/NewLogin/Field.svelte | 21 + .../newloginapp/NewLogin/NewLoginForm.svelte | 50 ++ .../newloginapp/NewLogin/UserSelector.svelte | 37 ++ src/apps/core/newloginapp/NewLoginApp.ts | 40 ++ src/apps/core/newloginapp/runtime.ts | 524 ++++++++++++++++++ src/apps/core/newloginapp/store.ts | 60 ++ src/apps/core/newloginapp/types.ts | 40 ++ src/apps/core/switchserver/runtime.ts | 2 + src/css/apps/core/loginapp.css | 2 + src/css/apps/core/newloginapp.css | 117 ++++ src/css/apps/core/newloginapp/loginform.css | 65 +++ .../apps/core/newloginapp/poweroptions.css | 44 ++ .../apps/core/newloginapp/userselector.css | 71 +++ .../runtimes/INewLoginAppRuntime.ts | 37 ++ src/ts/config.ts | 41 +- src/ts/daemon/contexts/preferences.ts | 1 + src/ts/daemon/index.ts | 2 +- src/ts/state/store.ts | 2 +- 19 files changed, 1201 insertions(+), 11 deletions(-) create mode 100644 src/apps/core/newloginapp/NewLogin.svelte create mode 100755 src/apps/core/newloginapp/NewLogin/Field.svelte create mode 100644 src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte create mode 100644 src/apps/core/newloginapp/NewLogin/UserSelector.svelte create mode 100644 src/apps/core/newloginapp/NewLoginApp.ts create mode 100644 src/apps/core/newloginapp/runtime.ts create mode 100644 src/apps/core/newloginapp/store.ts create mode 100644 src/apps/core/newloginapp/types.ts create mode 100644 src/css/apps/core/newloginapp.css create mode 100644 src/css/apps/core/newloginapp/loginform.css create mode 100644 src/css/apps/core/newloginapp/poweroptions.css create mode 100644 src/css/apps/core/newloginapp/userselector.css create mode 100644 src/interfaces/runtimes/INewLoginAppRuntime.ts diff --git a/src/apps/core/newloginapp/NewLogin.svelte b/src/apps/core/newloginapp/NewLogin.svelte new file mode 100644 index 000000000..79819fca5 --- /dev/null +++ b/src/apps/core/newloginapp/NewLogin.svelte @@ -0,0 +1,56 @@ + + +
+ {#if !$State.hideProfileImage} +
+ + {#if $State.displayName} +

{$State.displayName}

+ {/if} + {/if} + + {#if $Status.variant === LoginStatusVariant.Error} +

+ {@html $Status.content} +

+ + {:else if $Status.variant === LoginStatusVariant.Loading} +

+ + {@html $Status.content} +

+ {:else} + + {/if} + + {#if $Status.variant === LoginStatusVariant.None} + + +
+ + +
+ {/if} + + {#if ServerInfo?.loginBottomText} +

+ {[ServerInfo?.loginBottomText, import.meta.env.DW_PREVIEW_DEP_BRANCH].filter(Boolean).join(" -- ")} +

+ {/if} +
diff --git a/src/apps/core/newloginapp/NewLogin/Field.svelte b/src/apps/core/newloginapp/NewLogin/Field.svelte new file mode 100755 index 000000000..0b9d27ede --- /dev/null +++ b/src/apps/core/newloginapp/NewLogin/Field.svelte @@ -0,0 +1,21 @@ + + +
+ + +
diff --git a/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte b/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte new file mode 100644 index 000000000..38be6d5dd --- /dev/null +++ b/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte @@ -0,0 +1,50 @@ + + +
+
+ {#if !$State.selectedUser} + + {/if} + +
+
+ +
+
+ + + +{#if !$State.selectedUser && !ServerInfo?.disableRegistration} + +{/if} diff --git a/src/apps/core/newloginapp/NewLogin/UserSelector.svelte b/src/apps/core/newloginapp/NewLogin/UserSelector.svelte new file mode 100644 index 000000000..11c21b0d9 --- /dev/null +++ b/src/apps/core/newloginapp/NewLogin/UserSelector.svelte @@ -0,0 +1,37 @@ + + +{#if $Persistence.users?.length} +
+ {#each $Persistence.users as user (user.userId)} +
+ + +
+ {/each} +
+ +
+
+{/if} diff --git a/src/apps/core/newloginapp/NewLoginApp.ts b/src/apps/core/newloginapp/NewLoginApp.ts new file mode 100644 index 000000000..f57868dae --- /dev/null +++ b/src/apps/core/newloginapp/NewLoginApp.ts @@ -0,0 +1,40 @@ +import "$css/apps/core/newloginapp.css"; +import type { App } from "$types/apps/app"; +import NewLogin from "./NewLogin.svelte"; +import { NewLoginAppRuntime } from "./runtime"; + +const NewLoginApp: App = { + metadata: { + name: "LogonUI", + author: "Izaak Kuipers", + version: "10.0.0", + icon: "PasswordIcon", + appGroup: "coreApps", + }, + size: { w: NaN, h: NaN }, + minSize: { w: NaN, h: NaN }, + maxSize: { w: NaN, h: NaN }, + position: {}, + state: { + minimized: false, + maximized: false, + resizable: false, + fullscreen: true, + headless: true, + }, + controls: { + minimize: false, + maximize: false, + close: false, + }, + assets: { + component: NewLogin as any, + runtime: NewLoginAppRuntime, + }, + core: true, + hidden: true, + vital: true, + id: "NewLoginApp", +}; + +export default NewLoginApp; diff --git a/src/apps/core/newloginapp/runtime.ts b/src/apps/core/newloginapp/runtime.ts new file mode 100644 index 000000000..e3eb5f11b --- /dev/null +++ b/src/apps/core/newloginapp/runtime.ts @@ -0,0 +1,524 @@ +import TotpAuthGuiApp from "$apps/components/totpauthgui/TotpAuthGui"; +import type { IAppProcess } from "$interfaces/IAppProcess"; +import type { ICommandResult } from "$interfaces/ICommandResult"; +import type { IUserDaemon } from "$interfaces/IUserDaemon"; +import type { IUserConnector } from "$interfaces/modules/server/IUserConnector"; +import type { INewLoginAppRuntime } from "$interfaces/runtimes/INewLoginAppRuntime"; +import { AppProcess } from "$ts/apps/process"; +import { ConfigurationBuilder } from "$ts/config"; +import { UserDaemon } from "$ts/daemon"; +import { Env, GetConnector, Server, Stack, State, SysDispatch } from "$ts/env"; +import { ProfilePictures } from "$ts/images/pfp"; +import { Sleep } from "$ts/sleep"; +import { LoginUser } from "$ts/user/auth"; +import { Wallpapers } from "$ts/user/wallpaper/store"; +import { authcode } from "$ts/util"; +import { UUID } from "$ts/util/uuid"; +import { Store } from "$ts/writable"; +import type { AppProcessData } from "$types/apps/app"; +import type { RenderArgs } from "$types/system/process"; +import type { UserInfo } from "$types/user"; +import dayjs from "dayjs"; +import Cookies from "js-cookie"; +import { NewLoginUserDaemonStartOptions } from "./store"; +import { + LoginStatusVariant, + type LoginPersistence, + type LoginPersistenceUser, + type LoginScreenOptions, + type LoginState, + type LoginStatus, +} from "./types"; + +export class NewLoginAppRuntime extends AppProcess implements INewLoginAppRuntime { + private readonly PERSISTENCE_LS_ID = "arcNewLoginPersistence"; + private readonly DEFAULT_WALLPAPER_ID = "img39"; + private options?: LoginScreenOptions; + public State = Store({}); + public Status = Store({ + variant: LoginStatusVariant.None, + }); + public Persistence = Store(); + public Config = new ConfigurationBuilder() + .WithLocalStorage(this.PERSISTENCE_LS_ID) + .ReadsFrom(this.Persistence) + .WithDefaults({ users: [] }) + .ForProcess(this) + .Build(); + private onErrorDismissed?(): void; + + public get ServerInfo() { + return Server.serverInfo; + } + + public get WelcomeString() { + const hour = dayjs().hour(); + + if (hour < 6) return "Hi, go to sleep"; + if (hour < 12) return "Good morning"; + if (hour < 18) return "Good afternoon"; + return "Good evening"; + } + + //#region LIFECYCLE + + constructor(pid: number, parentPid: number, app: AppProcessData, options?: LoginScreenOptions) { + super(pid, parentPid, app); + + this.options = options; + this.safeMode = !!(options?.safeMode || Env.get("safemode")); + + if (this.safeMode) Env.set("safemode", true); + } + + async start() { + Env.set("loginapp_pid", this.pid); + + await this.Config.initialize(); + this.State.set(this.GetDefaultLoginState()); + + const lastUserId = this.Persistence().lastUserId; + if (lastUserId) this.LoadLoginStateUsingPersistenceFrom(lastUserId); + } + + async stop() { + Env.delete("loginapp_pid"); + } + + async render(args: RenderArgs) { + console.trace("what"); + if (this.ServerInfo?.freshBackend) { + await State.loadState("initialSetup"); + return false; + } + + if (!this.options?.type) { + State.getStateLoaders().main.removeAttribute("style"); + + const tokenResult = await this.LoadToken(); + const loginNotice = this.ServerInfo?.loginNotice; + + if (!tokenResult && loginNotice) { + this.ShowError(loginNotice); + } + + return; + } + + switch (this.options.type) { + case "logoff": + await this.PerformLogoff(this.options.userDaemon!); + return; + case "shutdown": + await this.PerformShutdown(this.options.userDaemon); + return; + case "restart": + await this.PerformRestart(this.options.userDaemon); + return; + + default: + throw new Error(`NewLoginAppRuntime.render: invalid login type '${this.options.type}'`); + } + } + + //#endregion + //#region PERSISTENCE + + SaveUserPersistence(info: UserInfo) { + this.Log(`SaveUserPersistence: ${info._id}`); + + this.Persistence.update((v) => { + const userConnector = Server.GetConn("UserConnector", ""); + const persistence: LoginPersistenceUser = { + displayName: info.preferences.account.displayName ?? info.username, + username: info.username, + userId: info._id, + profilePictureUrl: userConnector.PictureUrl(info._id), + wallpaperUrl: userConnector.LoginBgUrl(info._id), + administrator: info.admin, + }; + + const index = v.users.findIndex((u) => u.userId === info._id); + if (index < 0) v.users.push(persistence); + else v.users[index] = persistence; + + v.lastUserId = info._id; + + return v; + }); + } + + SetLastUsed(userId: string) { + this.Log(`SetLastUsed: ${userId}`); + this.Persistence.update((v) => { + v.lastUserId = userId; + + return v; + }); + } + + RemoveUser(userId: string) { + this.Log(`RemoveUser: ${userId}`); + this.Persistence.update((v) => { + v.users = v.users.filter((user) => user.userId !== userId); + + if (v.lastUserId === userId) { + v.lastUserId = v.users[0]?.userId ?? ""; + } + + return v; + }); + + this.LoadLoginStateUsingPersistenceFrom(this.Persistence().lastUserId ?? ""); + } + + SelectUser(userId: string) { + this.Persistence.update((v) => { + v.lastUserId = userId; + return v; + }); + this.LoadLoginStateUsingPersistenceFrom(userId); + } + + LoadLoginStateUsingPersistenceFrom(userId: string) { + this.Log(`LoadLoginStateUsingPersistenceFrom: ${userId}`); + + const userPersistence = this.Persistence().users.find((u) => u.userId === userId); + if (!userPersistence) { + this.State.set(this.GetDefaultLoginState()); + return; + } + + this.State.set({ + wallpaperUrl: userPersistence.wallpaperUrl, + profilePictureUrl: userPersistence.profilePictureUrl, + displayName: userPersistence.displayName, + selectedUser: userPersistence, + }); + } + + //#endregion + //#region LOGIN LOGIC + + async JumpstartUserDaemon(token: string, userInfo: UserInfo) { + this.Log(`Staring user daemon for '${userInfo.username}' (v10)`); + + this.ShowLoading(this.WelcomeString); + + const { success, result: userDaemon, errorMessage } = await UserDaemon.Hello(token, userInfo.username, userInfo); + + if (!success) { + this.ShowError(errorMessage ?? "Unknown error. Please contact support."); + return; + } + + const broadcast = (message: string) => { + if (!userDaemon?.preferences()?.enableVerboseLogin || !message) return; + this.ShowLoading(message); + }; + + this.ShowLoading("Saving token"); + this.SaveToken(userDaemon!); + + const result = await userDaemon?.startUserDaemon(NewLoginUserDaemonStartOptions(this), broadcast.bind(this)); + + this.SaveUserPersistence(userDaemon?.userInfo!); + + if (!result?.success) { + this.ShowError( + result?.errorMessage ?? "Unknown failure while attempting to initialize the User Daemon. Please contact support." + ); + } + } + + async PerformLogin(identity: string, password: string): Promise { + this.Log(`Trying login of '${identity}' using login v10`); + + this.ShowLoading(this.WelcomeString); + + const tokenResult = await LoginUser(identity, password); + + if (!tokenResult.success) { + await this.ShowErrorAndWait(tokenResult.errorMessage ?? "The credentials you entered are incorrect."); + + return; + } + + const userInfoResult = await this.GetUserInfo(tokenResult.result!); + if (!userInfoResult.success) { + this.ResetCookies(); + this.ShowErrorAndWait(userInfoResult.errorMessage ?? "Session token is invalid."); + + return; + } + + const userInfo = userInfoResult!.result; + this.ShowLoading(`Hi, ${userInfo!.preferences?.account?.displayName ?? userInfo!.username}!`); + + await this.JumpstartUserDaemon(tokenResult.result!, userInfo!); + } + + private async GetUserInfo(token: string): Promise> { + this.Log(`GetUserInfo`); + + return await GetConnector("UserConnector", token).Self(); + } + + public CreateUser(): void { + State?.loadState("initialSetup"); + } + + //#endregion + //#region 2FA + + public async AskFor2FA(userId?: string): Promise { + this.Log(`AskFor2FA`); + + const returnId = UUID(); + + return new Promise(async (r) => { + SysDispatch.subscribe("totp-unlock-success", ([id]) => { + if (id === returnId) r(true); + }); + + SysDispatch.subscribe("totp-unlock-cancel", ([id]) => { + if (id === returnId) r(false); + }); + + await Stack.spawn( + TotpAuthGuiApp.assets.runtime, + undefined, + userId, + this.pid, + { data: { ...TotpAuthGuiApp, overlay: true }, id: "TotpAuthGuiApp" }, + returnId + ); + }); + } + + //#endregion + //#region POWER + + private async PerformLogoff(userDaemon: IUserDaemon) { + this.Log(`Logging off user '${userDaemon.username}'`); + + this.ShowLoading(`Goodbye, ${userDaemon.preferences().account.displayName ?? userDaemon.username}!`); + + const verbose = userDaemon.preferences().enableVerboseLogin; + const broadcast = (message: string) => { + if (!verbose) return; + this.ShowLoading(message); + }; + + this.LoadLoginStateUsingPersistenceFrom(userDaemon.userInfo._id); + + broadcast("Stopping service host"); + await userDaemon.serviceHost?.spinDown(broadcast.bind(this)); + + broadcast("Stopping processes"); + for (const proc of Stack.renderer?.currentState.map((pid) => Stack.getProcess(pid) as IAppProcess) ?? []) { + if (!proc._disposed && proc.pid !== this.pid) { + await proc.killSelf(); + } + } + + broadcast("Notifying activity"); + await userDaemon.activity!.logActivity("logout"); + this.ResetCookies(); + + // mandatory timeout because of user daemon <> state handler timing mismatch + await Sleep(2000); + + broadcast("Stopping user contexts"); + await userDaemon.stopUserContexts(); + + broadcast("Discontinuing token"); + await userDaemon.account!.discontinueToken(); + + broadcast("Stopping User Daemon"); + await userDaemon.killSelf(); + + this.NoStatus(); + State?.getStateLoaders()?.main?.removeAttribute("style"); + } + + public async PerformShutdown(userDaemon?: IUserDaemon) { + this.Log(`Handling shutdown`); + + this.ShowLoading("Shutting down..."); + + const verbose = userDaemon?.preferences().enableVerboseLogin; + const broadcast = (message: string) => { + if (!verbose) return; + this.ShowLoading(message); + }; + + if (userDaemon) { + this.SaveUserPersistence(userDaemon.userInfo); + this.LoadLoginStateUsingPersistenceFrom(userDaemon.userInfo._id); + + broadcast("Stopping Service Host"); + await userDaemon.serviceHost?.spinDown(broadcast); + + broadcast("Stopping User Contexts"); + await userDaemon?.stopUserContexts(); + } + + // mandatory timeout because of user daemon <> state handler timing mismatch + await Sleep(2000); + + if (userDaemon) { + broadcast("Stopping User Daemon"); + await userDaemon.killSelf(); + } + + State?.loadState("turnedOff"); + } + + public async PerformRestart(userDaemon?: IUserDaemon) { + this.Log(`Handling restart`); + + this.ShowLoading("Restarting..."); + + const verbose = userDaemon?.preferences().enableVerboseLogin; + const broadcast = (message: string) => { + if (!verbose) return; + this.ShowLoading(message); + }; + + if (userDaemon) { + this.SaveUserPersistence(userDaemon.userInfo); + this.LoadLoginStateUsingPersistenceFrom(userDaemon.userInfo._id); + + broadcast("Stopping Service Host"); + await userDaemon.serviceHost?.spinDown(broadcast); + + broadcast("Stopping User Contexts"); + await userDaemon?.stopUserContexts(); + } + + await Sleep(2000); + + if (userDaemon) { + broadcast("Stopping User Daemon"); + await userDaemon.killSelf(); + } + + location.reload(); + } + + //#endregion + //#region COOKIES + + private SaveToken(userDaemon: IUserDaemon) { + const token = userDaemon.token; + const username = userDaemon.username; + + this.Log(`Saving token of '${userDaemon.username}' to cookies`); + + const cookieOptions: Cookies.CookieAttributes = { + path: "/", + secure: false, + expires: 30, + domain: location.hostname, + }; + + Cookies.set("arcToken", token, cookieOptions); + Cookies.set("arcUsername", username, cookieOptions); + } + + private async LoadToken() { + this.Log("Loading token from cookies"); + + const token = Cookies.get("arcToken"); + if (!token) return false; + + const userInfoResult = await this.GetUserInfo(token); + if (!userInfoResult.success) { + this.ResetCookies(); + + return false; + } + + this.LoadLoginStateUsingPersistenceFrom(userInfoResult.result!._id); + await this.JumpstartUserDaemon(token, userInfoResult.result!); + + return true; + } + + public ResetCookies() { + this.Log(`Resetting stored cookie state`); + + Cookies.remove("arcToken"); + Cookies.remove("arcUsername"); + } + + //#region UTILS + + private GetDefaultLoginState(): LoginState { + const wallpaperUrl = Server.serverInfo?.loginWallpaper + ? `${Server.url}/loginbg${authcode()}` + : Wallpapers[this.DEFAULT_WALLPAPER_ID].url; + + const profilePictureUrl = ProfilePictures.def; + + return { + displayName: "", + wallpaperUrl, + profilePictureUrl, + }; + } + + public DismissError(): void { + this.Log("DismissError"); + + if (this.Status().variant !== LoginStatusVariant.Error) return; + + this.Status.update((v) => { + v.variant = LoginStatusVariant.None; + return v; + }); + + this.onErrorDismissed?.(); + this.onErrorDismissed = undefined; + } + + public ShowError(message: string) { + this.Log(`ShowError: ${message}`); + if (this.Status().variant === LoginStatusVariant.Error) return; + + this.Status.update((v) => { + v.variant = LoginStatusVariant.Error; + v.content = message; + + return v; + }); + } + + public async ShowErrorAndWait(message: string): Promise { + this.Log(`ShowErrorAndWait: ${message}`); + return new Promise((resolve) => { + this.onErrorDismissed = () => resolve(); + this.ShowError(message); + }); + } + + public ShowLoading(message: string) { + this.Log(`ShowLoading: ${message}`); + this.Status.update((v) => { + v.variant = LoginStatusVariant.Loading; + v.content = message; + return v; + }); + } + + private NoStatus() { + this.Log(`NoStatus`); + this.Status.set({ + variant: LoginStatusVariant.None, + content: "", + }); + } + + //#endregion +} diff --git a/src/apps/core/newloginapp/store.ts b/src/apps/core/newloginapp/store.ts new file mode 100644 index 000000000..3c53282d3 --- /dev/null +++ b/src/apps/core/newloginapp/store.ts @@ -0,0 +1,60 @@ +import type { INewLoginAppRuntime } from "$interfaces/runtimes/INewLoginAppRuntime"; +import { Daemon, SoundBus, State } from "$ts/env"; +import { CommandResult } from "$ts/result"; +import type { UserDaemonStartOptions } from "$types/daemon"; + +export function NewLoginUserDaemonStartOptions(runtime: INewLoginAppRuntime): UserDaemonStartOptions { + return { + onUserInfo: async (info) => { + runtime.SaveUserPersistence(info); + runtime.LoadLoginStateUsingPersistenceFrom(info._id); + + if (info.hasTotp && info.restricted) { + runtime.ShowLoading("Requesting 2FA"); + + const unlocked = await runtime.AskFor2FA(info._id); + + if (!unlocked) { + await Daemon.account!.discontinueToken(); + await Daemon.killSelf(); + runtime.ResetCookies(); + return CommandResult.Error("You didn't enter a valid 2FA code!"); + } + } + + if (info.isSystem) { + await runtime.ShowErrorAndWait( + "This is a system account. Changes you make here can cause external ArcOS services to stop working. To continue, click Okay." + ); + } + + runtime.ShowLoading(runtime.WelcomeString); + + return CommandResult.Ok(); + }, + startStages: [ + "filesystem", + "preferencesSync", + "notifyLogin", + "serviceHost", + "firstRun", + "driveNotifierWatcher", + "indexing", + "statusRefresh", + "letsGo", + "workspaces", + "autorun", + ], + stageCallbacks: { + preferencesSync: async (_daemon, broadcast) => { + broadcast("Reading profile customization"); + }, + letsGo: async (daemon) => { + await State?.loadState("desktop", { userDaemon: daemon }, true); + SoundBus?.playSound("arcos.system.logon"); + daemon.renderer!.setAppRendererClasses(daemon.preferences()); + daemon.checks!.checkNightly(); + }, + }, + }; +} diff --git a/src/apps/core/newloginapp/types.ts b/src/apps/core/newloginapp/types.ts new file mode 100644 index 000000000..d2ac76392 --- /dev/null +++ b/src/apps/core/newloginapp/types.ts @@ -0,0 +1,40 @@ +import type { IUserDaemon } from "$interfaces/IUserDaemon"; + +export interface LoginState { + wallpaperUrl?: string; + profilePictureUrl?: string; + displayName?: string; + selectedUser?: LoginPersistenceUser; + hideProfileImage?: boolean; +} + +export interface LoginStatus { + variant: LoginStatusVariant; + content?: string; +} + +export enum LoginStatusVariant { + None, + Error, + Loading, +} + +export interface LoginPersistence { + lastUserId?: string; + users: LoginPersistenceUser[]; +} + +export interface LoginPersistenceUser { + userId: string; + username: string; + displayName: string; + wallpaperUrl: string; + profilePictureUrl: string; + administrator: boolean; +} + +export interface LoginScreenOptions { + userDaemon?: IUserDaemon; + type?: string; + safeMode?: boolean; +} diff --git a/src/apps/core/switchserver/runtime.ts b/src/apps/core/switchserver/runtime.ts index 54283b87b..5b558f13d 100644 --- a/src/apps/core/switchserver/runtime.ts +++ b/src/apps/core/switchserver/runtime.ts @@ -59,6 +59,8 @@ export class SwitchServerRuntime extends AppProcess implements ISwitchServerRunt Cookies.remove("arcToken"); Cookies.remove("arcUsername"); localStorage.removeItem("arcLoginPersistence"); + localStorage.removeItem("arcNewLoginPersistence"); + localStorage.removeItem("arcUsername"); } location.reload(); diff --git a/src/css/apps/core/loginapp.css b/src/css/apps/core/loginapp.css index 053abaa92..6dfdb9f2d 100755 --- a/src/css/apps/core/loginapp.css +++ b/src/css/apps/core/loginapp.css @@ -162,10 +162,12 @@ div.window#loginApp > div.body div.login-form button.continue:active { opacity: 1; background-color: #fff1; } + div.window#loginApp > div.body div.login-form button.continue:disabled span.lucide, div.window#loginApp > div.body div.login-form button.continue:active span.lucide { color: #aaa !important; } + div.window#loginApp > div.body p.error-message { font-size: 16px; max-width: 550px; diff --git a/src/css/apps/core/newloginapp.css b/src/css/apps/core/newloginapp.css new file mode 100644 index 000000000..2cf612774 --- /dev/null +++ b/src/css/apps/core/newloginapp.css @@ -0,0 +1,117 @@ +@import url("./newloginapp/loginform.css"); +@import url("./newloginapp/userselector.css"); +@import url("./newloginapp/poweroptions.css"); + +#NewLoginApp > div.body div.login-screen { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: var(--url); + background-size: cover; + background-position: center; + transition: all 0.3s !important; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 30px; + + &.safemode { + outline: #ff0 2px solid !important; + outline-offset: -2px !important ; + + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } + } + + &.server-bg { + background-image: var(--url); + background-position: center; + background-size: cover; + } + + &::after { + content: ""; + display: block; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #0007; + z-index: -1; + } + + #NewLoginApp > div.body div.login-screen > * { + position: relative; + z-index: 100; + } + + button.login-action { + padding: 8px 20px; + background-color: #fff1; + border-radius: 10px; + backdrop-filter: blur(var(--blur)); + transition: + scale 0.2s, + background-color 0.2s; + border: #fff1 1px solid; + + &:hover { + background-color: #fff2; + } + + &:active { + scale: 0.95; + } + } + + .profile-picture { + width: 128px; + height: 128px; + border-radius: 50%; + background-image: var(--src); + background-size: cover; + background-position: center; + transition: background-image 0.3s; + } + + h1.display-name { + font-size: 20px; + margin: -10px 0; + text-shadow: 0px 0px 10px #000; + } + + button.password-reset { + color: inherit; + text-decoration: none; + translate: 0 -10px; + } + + p.loading { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + } + + p.message { + font-size: 16px; + } + + p.bottom-text { + position: absolute; + bottom: 30px; + left: 0; + width: 100%; + text-align: center; + font-size: 16px; + opacity: 0.5; + } +} diff --git a/src/css/apps/core/newloginapp/loginform.css b/src/css/apps/core/newloginapp/loginform.css new file mode 100644 index 000000000..eedf9bd02 --- /dev/null +++ b/src/css/apps/core/newloginapp/loginform.css @@ -0,0 +1,65 @@ +#NewLoginApp > div.body div.login-form { + display: flex; + align-items: end; + gap: 10px; + width: 220px; + + div.field { + display: flex; + align-items: center; + width: 220px; + height: 37px; + border-radius: 10px; + background-color: #fff1; + border: #fff2 1px solid; + overflow: hidden; + backdrop-filter: blur(var(--blur)); + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + span.lucide { + display: flex; + justify-content: center; + align-items: center; + width: 36px; + height: 100%; + border-right: #fff2 1px solid; + font-size: 16px; + } + + input { + width: 0; + height: 100%; + flex-grow: 1; + border-radius: 0; + border: none; + background-color: transparent; + + ::-webkit-input-placeholder { + color:#fff5 + } + } + } + + button.continue { + display: flex; + justify-content: center; + align-items: center; + width: 37px; + height: 37px; + border: #fff2 1px solid; + background-color: #fff3; + padding: 0; + border-radius: 50%; + font-size: 16px; + backdrop-filter: blur(var(--blur)); + } + + button.continue span { + display: block; + height: 16px; + } +} diff --git a/src/css/apps/core/newloginapp/poweroptions.css b/src/css/apps/core/newloginapp/poweroptions.css new file mode 100644 index 000000000..4f9c421a8 --- /dev/null +++ b/src/css/apps/core/newloginapp/poweroptions.css @@ -0,0 +1,44 @@ +#NewLoginApp > div.body div.power-options { + position: absolute; + bottom: 30px; + right: 30px; + display: flex; + align-items: center; + gap: 20px; + z-index: 10000; + + button { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + width: 36px; + height: 36px; + font-size: 24px; + border-radius: 50%; + background-color: #fff2; + border: none; + font-size: 16px; + transition: + opacity 0.2s, + scale 0.2s; + outline: #fff3 1px solid; + outline-offset: -1px; + + &:hover { + scale: 1.1; + } + + &:active { + opacity: 0.7; + scale: 0.95; + } + + &.shutdown { + background-color: #fff4; + width: 50px; + height: 50px; + font-size: 24px; + } + } +} diff --git a/src/css/apps/core/newloginapp/userselector.css b/src/css/apps/core/newloginapp/userselector.css new file mode 100644 index 000000000..e390766d3 --- /dev/null +++ b/src/css/apps/core/newloginapp/userselector.css @@ -0,0 +1,71 @@ +#NewLoginApp > div.body div.user-selector { + position: fixed; + bottom: 30px; + left: 30px; + width: 250px; + + div.user-option { + display: flex; + align-items: center; + border-radius: 10px; + transition: + background-color 0.2s, + backdrop-filter 0.1s; + + &:hover, + &.selected { + background-color: #fff1; + backdrop-filter: blur(var(--blur)); + } + + button.trigger { + display: flex; + align-items: center; + padding: 10px; + gap: 10px; + background-color: transparent; + width: 0; + flex-grow: 1; + text-align: left; + + h1 { + font-size: 16px; + margin-bottom: 2px; + line-height: 19px; + font-weight: normal; + } + + p.role { + font-size: 12px; + opacity: 0.5; + } + } + &.add-user span.lucide { + display: flex; + justify-content: center; + align-items: center; + width: 48px; + height: 48px; + font-size: 24px; + background-color: #fff1; + border: #fff1 1px solid; + border-radius: 50%; + } + + button.delete { + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + padding: 0; + background-color: transparent; + font-size: 12px; + margin-right: 10px; + } + + &:not(.selected) button.delete { + display: none; + } + } +} diff --git a/src/interfaces/runtimes/INewLoginAppRuntime.ts b/src/interfaces/runtimes/INewLoginAppRuntime.ts new file mode 100644 index 000000000..3dd8e8fbe --- /dev/null +++ b/src/interfaces/runtimes/INewLoginAppRuntime.ts @@ -0,0 +1,37 @@ +import type { LoginPersistence, LoginState, LoginStatus } from "$apps/core/newloginapp/types"; +import type { IAppProcess } from "$interfaces/IAppProcess"; +import type { IConfigurator } from "$interfaces/IConfigurator"; +import type { IUserDaemon } from "$interfaces/IUserDaemon"; +import type { ServerInfo } from "$types/server"; +import type { ReadableStore } from "$types/shared/writable"; +import type { UserInfo } from "$types/user"; + +export interface INewLoginAppRuntime extends IAppProcess { + State: ReadableStore; + Status: ReadableStore; + Persistence: ReadableStore; + Config: IConfigurator; + + get ServerInfo(): ServerInfo | undefined; + get WelcomeString(): string; + + SaveUserPersistence(info: UserInfo): void; + SetLastUsed(userId: string): void; + RemoveUser(userId: string): void; + SelectUser(userId: string): void; + LoadLoginStateUsingPersistenceFrom(userId: string): void; + + JumpstartUserDaemon(token: string, userInfo: UserInfo): Promise; + PerformLogin(identity: string, password: string): Promise; + AskFor2FA(userId?: string): Promise; + CreateUser(): void; + + PerformShutdown(userDaemon?: IUserDaemon): Promise; + PerformRestart(userDaemon?: IUserDaemon): Promise; + + DismissError(): void; + ShowError(message: string): void; + ShowErrorAndWait(message: string): Promise; + ShowLoading(message: string): void; + ResetCookies(): void; +} diff --git a/src/ts/config.ts b/src/ts/config.ts index 5f3c066f6..08a15bb75 100644 --- a/src/ts/config.ts +++ b/src/ts/config.ts @@ -13,6 +13,7 @@ export class ConfigurationBuilder { #defaults?: T; #process?: IProcess; #cooldown?: number; + #localStorageKey?: string; #built = false; ReadsFrom(store: ReadableStore) { @@ -24,6 +25,7 @@ export class ConfigurationBuilder { } WritesTo(path: string) { + if (this.#localStorageKey) throw new Error("ConfigurationBuilder can't write to both a file and LocalStorage"); if (this.#savePath) throw new Error("ConfigurationBuilder savePath can only be set once"); if (this.#built) throw new Error("ConfigurationBuilder has to be fully configured before building"); @@ -31,6 +33,15 @@ export class ConfigurationBuilder { return this; } + WithLocalStorage(key: string) { + if (this.#savePath) throw new Error("ConfigurationBuilder can't write to both a file and LocalStorage"); + if (this.#localStorageKey) throw new Error("ConfigurationBuilder localStorageKey can only be set once"); + if (this.#built) throw new Error("ConfigurationBuilder has to be fully configured before building"); + + this.#localStorageKey = key; + return this; + } + WithDefaults(defaults: T) { if (this.#defaults) throw new Error("ConfigurationBuilder defaults can only be set once"); if (this.#built) throw new Error("ConfigurationBuilder has to be fully configured before building"); @@ -60,12 +71,13 @@ export class ConfigurationBuilder { const store = this.#store; const savePath = this.#savePath; + const localStorageKey = this.#localStorageKey; const defaults = this.#defaults; const process = this.#process; const cooldown = this.#cooldown; let initialRun = true; - if (!savePath) throw new Error("savePath is required. Use WritesTo to set"); + if (!savePath && !localStorageKey) throw new Error("savePath or localStorageKey is required. Use WritesTo or WithLocalStorage to set"); if (!store) throw new Error("store is required. Use ReadsFrom to set"); this.Log(`Building new configurator for ${savePath} (process ${process?.name ?? ""})`); @@ -80,15 +92,22 @@ export class ConfigurationBuilder { Log("Reading configuration"); - const content = await Fs.readFile(savePath!); - if (!content) return await this.writeConfiguration(defaults); + if (localStorageKey) { + const content = localStorage.getItem(localStorageKey); + if (!content) return await this.writeConfiguration(defaults); + + return tryJsonParse(content); + } else { + const content = await Fs.readFile(savePath!); + if (!content) return await this.writeConfiguration(defaults); - try { - const obj = tryJsonParse(arrayBufferToText(content)); + try { + const obj = tryJsonParse(arrayBufferToText(content)); - return obj; - } catch { - return await this.writeConfiguration(defaults); + return obj; + } catch { + return await this.writeConfiguration(defaults); + } } } @@ -103,7 +122,11 @@ export class ConfigurationBuilder { Log("Writing configuration"); - await Fs.writeFile(savePath!, textToBlob(JSON.stringify(configuration, null, 2))); + if (localStorageKey) { + localStorage.setItem(localStorageKey, JSON.stringify(configuration)); + } else { + await Fs.writeFile(savePath!, textToBlob(JSON.stringify(configuration, null, 2))); + } return configuration; } diff --git a/src/ts/daemon/contexts/preferences.ts b/src/ts/daemon/contexts/preferences.ts index f6e9b00a0..be3bdc66b 100644 --- a/src/ts/daemon/contexts/preferences.ts +++ b/src/ts/daemon/contexts/preferences.ts @@ -173,6 +173,7 @@ export class PreferencesUserContext extends UserContext implements IPreferencesU Daemon!.workspaces?.syncVirtualDesktops(v); Daemon!.wallpaper?.updateWallpaper(v); Daemon!.updateGlobalDispatch(); + Daemon!.userInfo.preferences = v; }); Daemon!.preferencesCtx!.preferencesUnsubscribe = unsubscribe; diff --git a/src/ts/daemon/index.ts b/src/ts/daemon/index.ts index c6958a502..59b1a4894 100644 --- a/src/ts/daemon/index.ts +++ b/src/ts/daemon/index.ts @@ -301,7 +301,7 @@ export class UserDaemon extends Process implements IUserDaemon { await this.workspaces!.startVirtualDesktops(); }); - await performStartStage("autorun", "Running autorun", async () => { + await performStartStage("autorun", "Runnping autorun", async () => { this.wallpaper?.lastWallpaper.set(""); await this.wallpaper?.updateWallpaper(this.preferences()); await this.init?.handleShellAndAutorun(); diff --git a/src/ts/state/store.ts b/src/ts/state/store.ts index f8bb2fd95..583cae969 100755 --- a/src/ts/state/store.ts +++ b/src/ts/state/store.ts @@ -16,7 +16,7 @@ const globs = Object.values( import.meta.glob([ "$apps/core/bootscreen/bootScreen", "$apps/core/initialsetup/initialSetupWizard", - "$apps/core/loginapp/loginApp", + "$apps/core/newloginapp/NewLoginApp", "$apps/core/switchserver/SwitchServer", ]) ); From 4b3b41957760db861f049f7140fed57892d44985 Mon Sep 17 00:00:00 2001 From: Izaak Kuipers Date: Sun, 9 Aug 2026 18:13:56 +0200 Subject: [PATCH 2/3] IPswdResetWizard --- .../Pages/EnterUsername.svelte | 30 ++++ .../pswdresetwizard/Pages/NewPassword.svelte | 25 ++++ .../pswdresetwizard/Pages/RestartFirst.svelte | 11 ++ .../pswdresetwizard/Pages/Verification.svelte | 28 ++++ .../pswdresetwizard/PswdResetWizard.svelte | 39 ++++++ .../pswdresetwizard/PswdResetWizardApp.ts | 45 ++++++ .../components/pswdresetwizard/runtime.ts | 132 ++++++++++++++++++ src/apps/components/pswdresetwizard/store.ts | 81 +++++++++++ src/apps/components/pswdresetwizard/types.ts | 7 + .../newloginapp/NewLogin/NewLoginForm.svelte | 2 +- src/apps/core/newloginapp/runtime.ts | 31 ++++ src/css/apps/components/pswdresetwizard.css | 80 +++++++++++ .../modules/server/IUserConnector.ts | 9 ++ .../runtimes/INewLoginAppRuntime.ts | 7 +- .../runtimes/IPswdResetWizardRuntime.ts | 22 +++ src/ts/apps/store.ts | 1 + .../mods/server/connectors/UserConnector.ts | 31 ++++ src/types/user/pswdreset.ts | 14 ++ 18 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 src/apps/components/pswdresetwizard/Pages/EnterUsername.svelte create mode 100644 src/apps/components/pswdresetwizard/Pages/NewPassword.svelte create mode 100644 src/apps/components/pswdresetwizard/Pages/RestartFirst.svelte create mode 100644 src/apps/components/pswdresetwizard/Pages/Verification.svelte create mode 100644 src/apps/components/pswdresetwizard/PswdResetWizard.svelte create mode 100644 src/apps/components/pswdresetwizard/PswdResetWizardApp.ts create mode 100644 src/apps/components/pswdresetwizard/runtime.ts create mode 100644 src/apps/components/pswdresetwizard/store.ts create mode 100644 src/apps/components/pswdresetwizard/types.ts create mode 100644 src/css/apps/components/pswdresetwizard.css create mode 100644 src/interfaces/runtimes/IPswdResetWizardRuntime.ts create mode 100644 src/types/user/pswdreset.ts diff --git a/src/apps/components/pswdresetwizard/Pages/EnterUsername.svelte b/src/apps/components/pswdresetwizard/Pages/EnterUsername.svelte new file mode 100644 index 000000000..e30b9be73 --- /dev/null +++ b/src/apps/components/pswdresetwizard/Pages/EnterUsername.svelte @@ -0,0 +1,30 @@ + + +

+ First, we need your username. To verify that it’s you, we’ll send an email with a verification code to the email address of your + account. +

+ +
+

Enter your username:

+
+ + +
+ {#if $ErrorMessage} +
+ + {$ErrorMessage} +
+ {/if} +
+ +

+ If you forgot your username, please talk to an Administrator in the ArcOS Discord server, and we'll help you recover your + account. +

diff --git a/src/apps/components/pswdresetwizard/Pages/NewPassword.svelte b/src/apps/components/pswdresetwizard/Pages/NewPassword.svelte new file mode 100644 index 000000000..13ea3437e --- /dev/null +++ b/src/apps/components/pswdresetwizard/Pages/NewPassword.svelte @@ -0,0 +1,25 @@ + + +

+ We’re almost there, {$RecoveryUsername}! To finish off, think of a strong password for your ArcOS account. Save it in your password + manager so that you don’t lose it. +

+ +
+

Enter a new password:

+
+ + +
+ {#if $ErrorMessage} +
+ + {$ErrorMessage} +
+ {/if} +
diff --git a/src/apps/components/pswdresetwizard/Pages/RestartFirst.svelte b/src/apps/components/pswdresetwizard/Pages/RestartFirst.svelte new file mode 100644 index 000000000..15af0306e --- /dev/null +++ b/src/apps/components/pswdresetwizard/Pages/RestartFirst.svelte @@ -0,0 +1,11 @@ +
+ + You have to restart ArcOS first. +
+ +

+ An ArcOS session was active before requesting a password reset. For your safety, please restart ArcOS before proceeding. This is + to prevent residual third-party code from that session from potentially stealing your account information. +

+ +

After restarting, click the Forgot password? link again to continue.

diff --git a/src/apps/components/pswdresetwizard/Pages/Verification.svelte b/src/apps/components/pswdresetwizard/Pages/Verification.svelte new file mode 100644 index 000000000..6ea7cc56b --- /dev/null +++ b/src/apps/components/pswdresetwizard/Pages/Verification.svelte @@ -0,0 +1,28 @@ + + +

+ We’ve sent a verification code to the email address associated with your account. Enter the code in the field below to continue: +

+ +
+

Verification code:

+
+ +
+ {#if $ErrorMessage} +
+ + {$ErrorMessage} +
+ {/if} +
+ +

+ The verification code is valid for 15 minutes. After this period has elapsed, you can request another verification code. Only + one code may be requested at a time. +

diff --git a/src/apps/components/pswdresetwizard/PswdResetWizard.svelte b/src/apps/components/pswdresetwizard/PswdResetWizard.svelte new file mode 100644 index 000000000..691bb78e4 --- /dev/null +++ b/src/apps/components/pswdresetwizard/PswdResetWizard.svelte @@ -0,0 +1,39 @@ + + +
+
+

Account Recovery

+

Follow these steps to reset your password.

+
+
+ {#if Component} + + {/if} +
+ {#if $Loading} +
+ +
+ {/if} +
+ + {#snippet rightContent()} + {#if $CurrentPage} + {#each $CurrentPage.buttons as button (button.caption)} + button.action()}> + {button.caption} + + {/each} + {/if} + {/snippet} + diff --git a/src/apps/components/pswdresetwizard/PswdResetWizardApp.ts b/src/apps/components/pswdresetwizard/PswdResetWizardApp.ts new file mode 100644 index 000000000..2e25b9d4a --- /dev/null +++ b/src/apps/components/pswdresetwizard/PswdResetWizardApp.ts @@ -0,0 +1,45 @@ +import "$css/apps/components/pswdresetwizard.css"; +import type { App } from "$types/apps/app"; +import PswdResetWizard from "./PswdResetWizard.svelte"; +import { PswdResetWizardRuntime } from "./runtime"; + +const PswdResetWizardApp: App = { + metadata: { + name: "Password reset wizard", + author: "Izaak Kuipers", + version: "1.0.0", + icon: "ComponentIcon", + }, + position: { centered: true }, + size: { + w: 480, + h: 400, + }, + minSize: { + w: 480, + h: 400, + }, + maxSize: { + w: 480, + h: 400, + }, + controls: { + minimize: false, + maximize: false, + close: true, + }, + state: { + minimized: false, + maximized: false, + resizable: false, + headless: true, + fullscreen: false, + }, + assets: { + runtime: PswdResetWizardRuntime, + component: PswdResetWizard as any, + }, + id: "PswdResetWizardApp", +}; + +export default PswdResetWizardApp; diff --git a/src/apps/components/pswdresetwizard/runtime.ts b/src/apps/components/pswdresetwizard/runtime.ts new file mode 100644 index 000000000..944b638b6 --- /dev/null +++ b/src/apps/components/pswdresetwizard/runtime.ts @@ -0,0 +1,132 @@ +import type { IUserConnector } from "$interfaces/modules/server/IUserConnector"; +import type { INewLoginAppRuntime } from "$interfaces/runtimes/INewLoginAppRuntime"; +import type { IPswdResetWizardRuntime } from "$interfaces/runtimes/IPswdResetWizardRuntime"; +import { AppProcess } from "$ts/apps/process"; +import { Daemon, Env, GetConnector, Kernel, Stack } from "$ts/env"; +import { GoodStatusIcon } from "$ts/images/status"; +import { MessageBox } from "$ts/util/dialog"; +import { Store } from "$ts/writable"; +import type { AppProcessData } from "$types/apps/app"; +import { PswdResetPages } from "./store"; +import type { PswdResetPage } from "./types"; + +export class PswdResetWizardRuntime extends AppProcess implements IPswdResetWizardRuntime { + public RecoveryUsername = Store(""); + public RecoveryCode = Store(""); + public NewPassword = Store(""); + + public Loading = Store(false); + public CurrentPage = Store(); + public ErrorMessage = Store(""); + + // for callback to login + public Finished = Store(false); + + public get LoginRuntime(): INewLoginAppRuntime { + const proc = Stack.getProcess(+Env.get("LOGINAPP_PID")); + if (!proc) throw new Error("Reference to NewLoginAppRuntime could not be obtained"); + + return proc; + } + + async onClose(): Promise { + this.Finished.set(true); + return true; + } + + private RecoveryUserId?: string; + private ResetToken?: string; + private Pages = PswdResetPages(this); + private get UserConnector(): IUserConnector { + return GetConnector("UserConnector"); + } + + constructor(pid: number, parentPid: number, app: AppProcessData) { + super(pid, parentPid, app); + + if (Daemon && !Daemon?._disposed) throw new Error("PswdResetWizard illegal invocation"); + } + + protected async start(): Promise { + const daemonHasRun = !!Kernel.Logs.find((log) => log.source === "UserDaemon.Hello" && log.message === "HELLO!"); + + this.SwitchPage(daemonHasRun ? "restartFirst" : "enterUsername"); + + this.Loading.subscribe((v) => { + if (v) this.ErrorMessage.set(""); + }); + } + + public async DoSendEmail() { + if (!this.RecoveryUsername()) return; + + this.Loading.set(true); + const result = await this.UserConnector.CreatePswdResetRequest(this.RecoveryUsername()); + this.Loading.set(false); + + if (!result.success) { + this.ErrorMessage.set(result.errorMessage ?? "An unknown error has occurred. Please talk to an administrator."); + return; + } + + this.RecoveryUserId = result.result?.userId; + this.SwitchPage("verification"); + } + + public async DoVerify() { + if (!this.RecoveryCode()) return; + + this.Loading.set(true); + const result = await this.UserConnector.VerifyPswdResetRequest(this.RecoveryUserId!, this.RecoveryCode()); + this.Loading.set(false); + + if (!result.success) { + this.ErrorMessage.set(result.errorMessage ?? "An unknown error has occurred. Please talk to an administrator."); + return; + } + + this.RecoveryUserId = result.result?.userId; + this.ResetToken = result.result?.resetToken; + this.SwitchPage("newPassword"); + } + + public async DoChangePassword() { + if (!this.NewPassword()) return; + + this.Loading.set(true); + const result = await this.UserConnector.ConsumePswdResetRequest(this.RecoveryUserId!, this.ResetToken!, this.NewPassword()); + this.Loading.set(false); + + if (!result.success) { + this.ErrorMessage.set(result.errorMessage ?? "An unknown error has occurred. Please talk to an administrator."); + return; + } + + MessageBox( + { + title: "Password changed", + message: "Good news! Your ArcOS account password has been changed. Click Okay to return to the login screen.", + buttons: [ + { + caption: "Okay", + action: () => { + this.closeWindow(); + }, + suggested: true, + }, + ], + sound: "arcos.dialog.info", + image: GoodStatusIcon, // no icon service here + }, + this.pid, + true + ); + } + + public SwitchPage(pageId: string) { + if (!this.Pages[pageId]) return; + + this.ErrorMessage.set(""); + this.CurrentPage.set(this.Pages[pageId]); + } +} diff --git a/src/apps/components/pswdresetwizard/store.ts b/src/apps/components/pswdresetwizard/store.ts new file mode 100644 index 000000000..48c331582 --- /dev/null +++ b/src/apps/components/pswdresetwizard/store.ts @@ -0,0 +1,81 @@ +import type { IPswdResetWizardRuntime } from "$interfaces/runtimes/IPswdResetWizardRuntime"; +import EnterUsername from "./Pages/EnterUsername.svelte"; +import NewPassword from "./Pages/NewPassword.svelte"; +import RestartFirst from "./Pages/RestartFirst.svelte"; +import Verification from "./Pages/Verification.svelte"; +import type { PswdResetPage } from "./types"; + +export function PswdResetPages(runtime: IPswdResetWizardRuntime): Record { + return { + restartFirst: { + component: RestartFirst as any, + buttons: [ + { + caption: "Cancel", + action: () => { + runtime.closeWindow(); + }, + }, + { + caption: "Restart ArcOS", + action: () => { + runtime.closeWindow(); + runtime.LoginRuntime.PerformRestart(); + }, + suggested: true, + }, + ], + }, + enterUsername: { + component: EnterUsername as any, + buttons: [ + { + caption: "Cancel", + action: () => { + runtime.closeWindow(); + }, + }, + { + caption: "Send email", + action: () => { + runtime.DoSendEmail(); + }, + suggested: true, + }, + ], + }, + verification: { + component: Verification as any, + buttons: [ + { + caption: "Cancel", + disabled: true as any, + action: () => {}, + }, + { + caption: "Verify", + suggested: true, + action: () => runtime.DoVerify(), + }, + ], + }, + newPassword: { + component: NewPassword as any, + buttons: [ + { + caption: "Cancel", + action: () => { + runtime.closeWindow(); + }, + }, + { + caption: "Change password", + action: () => { + runtime.DoChangePassword(); + }, + suggested: true, + }, + ], + }, + }; +} diff --git a/src/apps/components/pswdresetwizard/types.ts b/src/apps/components/pswdresetwizard/types.ts new file mode 100644 index 000000000..50407d6fe --- /dev/null +++ b/src/apps/components/pswdresetwizard/types.ts @@ -0,0 +1,7 @@ +import type { MessageBoxButton } from "$types/shared/messagebox"; +import type { Component } from "svelte"; + +export interface PswdResetPage { + component: Component; + buttons: (MessageBoxButton & { disabled?: boolean })[]; +} diff --git a/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte b/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte index 38be6d5dd..638b9fab9 100644 --- a/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte +++ b/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte @@ -43,7 +43,7 @@ - + {#if !$State.selectedUser && !ServerInfo?.disableRegistration} diff --git a/src/apps/core/newloginapp/runtime.ts b/src/apps/core/newloginapp/runtime.ts index e3eb5f11b..83d33a673 100644 --- a/src/apps/core/newloginapp/runtime.ts +++ b/src/apps/core/newloginapp/runtime.ts @@ -1,9 +1,12 @@ +import PswdResetWizardApp from "$apps/components/pswdresetwizard/PswdResetWizardApp"; import TotpAuthGuiApp from "$apps/components/totpauthgui/TotpAuthGui"; +import type { Constructs } from "$interfaces/common"; import type { IAppProcess } from "$interfaces/IAppProcess"; import type { ICommandResult } from "$interfaces/ICommandResult"; import type { IUserDaemon } from "$interfaces/IUserDaemon"; import type { IUserConnector } from "$interfaces/modules/server/IUserConnector"; import type { INewLoginAppRuntime } from "$interfaces/runtimes/INewLoginAppRuntime"; +import type { IPswdResetWizardRuntime } from "$interfaces/runtimes/IPswdResetWizardRuntime"; import { AppProcess } from "$ts/apps/process"; import { ConfigurationBuilder } from "$ts/config"; import { UserDaemon } from "$ts/daemon"; @@ -268,6 +271,34 @@ export class NewLoginAppRuntime extends AppProcess implements INewLoginAppRuntim State?.loadState("initialSetup"); } + public async ForgotPassword(): Promise { + await new Promise(async (resolve) => { + const proc = await Stack.spawn( + PswdResetWizardApp.assets.runtime as Constructs, + undefined, + "", + this.pid, + { + data: { ...PswdResetWizardApp, overlay: true }, + id: PswdResetWizardApp.id, + } + ); + + if (!proc) { + // todo + resolve(); + return; + } + + const unsub = proc.Finished.subscribe((v) => { + if (!v) return; + + resolve(); + unsub?.(); + }); + }); + } + //#endregion //#region 2FA diff --git a/src/css/apps/components/pswdresetwizard.css b/src/css/apps/components/pswdresetwizard.css new file mode 100644 index 000000000..453dbdddf --- /dev/null +++ b/src/css/apps/components/pswdresetwizard.css @@ -0,0 +1,80 @@ +#PswdResetWizardApp > div.body { + padding: 0; + display: flex; + flex-direction: column; + + div.top { + padding: 30px 20px 20px 20px; + display: flex; + flex-direction: column; + flex-grow: 1; + height: 0; + + div.header { + text-align: center; + margin-bottom: 30px; + + h1 { + font-size: 18px; + margin-bottom: 5px; + } + + p { + opacity: 0.5; + } + } + + p.info { + margin-bottom: auto; + } + + p.help { + opacity: 0.5; + font-style:italic; + font-size: 12px; + } + + div.field-wrapper { + height: 100px; + margin-top: 30px; + margin-bottom: 20px; + + h2 { + opacity: 0.5; + margin-bottom: 8px; + } + + div.field { + display: flex; + align-items: center; + height: 40px; + border: var(--button-glass-hover-bg) 1px solid; + border-radius: 10px; + overflow: hidden; + + span.lucide { + font-size: 16px; + width: 40px; + height: 38px; + border-right: var(--button-glass-hover-bg) 1px solid; + display: flex; + justify-content: center; + align-items: center; + } + + input { + width: 0; + flex-grow: 1; + border: none; + border-radius: 0; + } + + &.verification-code input { + text-align: center; + font-size: 18px; + font-family: "Source Code Pro"; + } + } + } + } +} diff --git a/src/interfaces/modules/server/IUserConnector.ts b/src/interfaces/modules/server/IUserConnector.ts index 0c5947e0c..ebc6cd4fe 100644 --- a/src/interfaces/modules/server/IUserConnector.ts +++ b/src/interfaces/modules/server/IUserConnector.ts @@ -1,6 +1,7 @@ import type { ICommandResult } from "$interfaces/ICommandResult"; import type { GlobalDispatchClient } from "$types/system/dispatch"; import type { PublicUserInfo, UserInfo, UserPreferences } from "$types/user"; +import type { PswdResetConsumptionResult, PswdResetCreateResult, PswdResetVerificationResult } from "$types/user/pswdreset"; import type { IServerConnector } from "../IServerManager"; // !tpa @@ -16,4 +17,12 @@ export declare class IUserConnector extends IServerConnector { DispatchKick(clientId: string): Promise; PictureUrl(userId: string): string; LoginBgUrl(userId: string): string; + + CreatePswdResetRequest(username: string): Promise>; + VerifyPswdResetRequest(userId: string, code: string): Promise>; + ConsumePswdResetRequest( + userId: string, + resetToken: string, + newPassword: string + ): Promise>; } diff --git a/src/interfaces/runtimes/INewLoginAppRuntime.ts b/src/interfaces/runtimes/INewLoginAppRuntime.ts index 3dd8e8fbe..48471fe2f 100644 --- a/src/interfaces/runtimes/INewLoginAppRuntime.ts +++ b/src/interfaces/runtimes/INewLoginAppRuntime.ts @@ -20,15 +20,16 @@ export interface INewLoginAppRuntime extends IAppProcess { RemoveUser(userId: string): void; SelectUser(userId: string): void; LoadLoginStateUsingPersistenceFrom(userId: string): void; - + JumpstartUserDaemon(token: string, userInfo: UserInfo): Promise; PerformLogin(identity: string, password: string): Promise; AskFor2FA(userId?: string): Promise; + ForgotPassword(): Promise; CreateUser(): void; - + PerformShutdown(userDaemon?: IUserDaemon): Promise; PerformRestart(userDaemon?: IUserDaemon): Promise; - + DismissError(): void; ShowError(message: string): void; ShowErrorAndWait(message: string): Promise; diff --git a/src/interfaces/runtimes/IPswdResetWizardRuntime.ts b/src/interfaces/runtimes/IPswdResetWizardRuntime.ts new file mode 100644 index 000000000..0bb6af0d6 --- /dev/null +++ b/src/interfaces/runtimes/IPswdResetWizardRuntime.ts @@ -0,0 +1,22 @@ +import type { PswdResetPage } from "$apps/components/pswdresetwizard/types"; +import type { IAppProcess } from "$interfaces/IAppProcess"; +import type { BooleanStore, ReadableStore, StringStore } from "$types/shared/writable"; +import type { INewLoginAppRuntime } from "./INewLoginAppRuntime"; + +export interface IPswdResetWizardRuntime extends IAppProcess { + RecoveryUsername: StringStore; + RecoveryCode: StringStore; + NewPassword: StringStore; + + Finished: BooleanStore; + Loading: BooleanStore; + CurrentPage: ReadableStore; + ErrorMessage: StringStore; + + get LoginRuntime(): INewLoginAppRuntime; + + DoSendEmail(): Promise; + DoVerify(): Promise; + DoChangePassword(): Promise; + SwitchPage(pageId: string): void; +} diff --git a/src/ts/apps/store.ts b/src/ts/apps/store.ts index a2f9870d9..e34d77b4c 100755 --- a/src/ts/apps/store.ts +++ b/src/ts/apps/store.ts @@ -42,6 +42,7 @@ export const BuiltinAppImportPathAbsolutes = import.meta.glob([ "$apps/components/arctermcolors/ArcTermColors", "$apps/components/masteroptions/MasterOptions", "$apps/components/sendbetafeedback/SendBetaFeedbackApp", + "$apps/components/pswdresetwizard/PswdResetWizardApp", "$apps/user/advsystemsettings/AdvSystemSettings", "$apps/user/appstore/AppStore", "$apps/user/arcterm/ArcTerm", diff --git a/src/ts/kernel/mods/server/connectors/UserConnector.ts b/src/ts/kernel/mods/server/connectors/UserConnector.ts index baf5bb158..4dbb789b8 100644 --- a/src/ts/kernel/mods/server/connectors/UserConnector.ts +++ b/src/ts/kernel/mods/server/connectors/UserConnector.ts @@ -6,6 +6,7 @@ import { authcode } from "$ts/util"; import { toForm } from "$ts/util/form"; import type { GlobalDispatchClient } from "$types/system/dispatch"; import type { PublicUserInfo, UserInfo, UserPreferences } from "$types/user"; +import type { PswdResetConsumptionResult, PswdResetCreateResult, PswdResetVerificationResult } from "$types/user/pswdreset"; import { ServerConnector } from "."; export class UserConnector extends ServerConnector implements IUserConnector { @@ -108,4 +109,34 @@ export class UserConnector extends ServerConnector implements IUserConnector { const code = authcode(); return `${Server.url}/user/loginbg/${userId}${code}${code ? "&" : "?"}${Date.now()}`; } + + async CreatePswdResetRequest(username: string): Promise> { + try { + return CommandResult.FromResponse(await this.server.post("/pswdreset", toForm({ username }))); + } catch (e) { + return CommandResult.AxiosError(e); + } + } + + async VerifyPswdResetRequest(userId: string, code: string): Promise> { + try { + return CommandResult.FromResponse(await this.server.post("/pswdreset/verify", toForm({ userId, code }))); + } catch (e) { + return CommandResult.AxiosError(e); + } + } + + async ConsumePswdResetRequest( + userId: string, + resetToken: string, + newPassword: string + ): Promise> { + try { + return CommandResult.FromResponse( + await this.server.post("/pswdreset/consume", toForm({ userId, resetToken, newPassword })) + ); + } catch (e) { + return CommandResult.AxiosError(e); + } + } } diff --git a/src/types/user/pswdreset.ts b/src/types/user/pswdreset.ts new file mode 100644 index 000000000..8b2bc5770 --- /dev/null +++ b/src/types/user/pswdreset.ts @@ -0,0 +1,14 @@ +export interface PswdResetConsumptionResult { + success: boolean; + changed: boolean; +} + +export interface PswdResetCreateResult { + created: boolean; + userId: string; +} + +export interface PswdResetVerificationResult { + userId: string; + resetToken: string; +} \ No newline at end of file From d3ee81abe7e8db563eb10dda19b5ea7986844d0b Mon Sep 17 00:00:00 2001 From: Izaak Kuipers Date: Sun, 9 Aug 2026 19:05:15 +0200 Subject: [PATCH 3/3] cleanup --- src/apps/components/pswdresetwizard/runtime.ts | 11 ++++++++++- src/apps/components/sharecreategui/runtime.ts | 9 ++++----- .../ShareMgmtGui/Overlays/ChangePassword.svelte | 6 +++--- src/apps/core/newloginapp/runtime.ts | 1 - src/interfaces/services/IShareManager.ts | 5 +++-- src/ts/servicehost/services/ShareMgmt/index.ts | 9 +++++---- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/apps/components/pswdresetwizard/runtime.ts b/src/apps/components/pswdresetwizard/runtime.ts index 944b638b6..f1e410369 100644 --- a/src/apps/components/pswdresetwizard/runtime.ts +++ b/src/apps/components/pswdresetwizard/runtime.ts @@ -4,9 +4,11 @@ import type { IPswdResetWizardRuntime } from "$interfaces/runtimes/IPswdResetWiz import { AppProcess } from "$ts/apps/process"; import { Daemon, Env, GetConnector, Kernel, Stack } from "$ts/env"; import { GoodStatusIcon } from "$ts/images/status"; +import { checkPasswordStrength } from "$ts/util"; import { MessageBox } from "$ts/util/dialog"; import { Store } from "$ts/writable"; import type { AppProcessData } from "$types/apps/app"; +import { PasswordStrengthCaptions, type PasswordStrength } from "$types/user"; import { PswdResetPages } from "./store"; import type { PswdResetPage } from "./types"; @@ -91,7 +93,14 @@ export class PswdResetWizardRuntime extends AppProcess implements IPswdResetWiza } public async DoChangePassword() { - if (!this.NewPassword()) return; + const password = this.NewPassword(); + if (!password) return; + + const passwordStrength = checkPasswordStrength(password).value as PasswordStrength; + if (passwordStrength === "tooWeak") { + this.ErrorMessage.set(`Password is ${PasswordStrengthCaptions[passwordStrength]}!`); + return; + } this.Loading.set(true); const result = await this.UserConnector.ConsumePswdResetRequest(this.RecoveryUserId!, this.ResetToken!, this.NewPassword()); diff --git a/src/apps/components/sharecreategui/runtime.ts b/src/apps/components/sharecreategui/runtime.ts index 45419c073..664ba329c 100755 --- a/src/apps/components/sharecreategui/runtime.ts +++ b/src/apps/components/sharecreategui/runtime.ts @@ -32,15 +32,14 @@ export class ShareCreateGuiRuntime extends AppProcess implements IShareCreateGui if (!name || !password) return; - const result = await this.shares.createShare(name, password); // Let's create the share + const createResult = await this.shares.createShare(name, password); // Let's create the share - if (!result) { + if (!createResult.success) { // create failed or smth MessageBox( { title: "Failed to create share", - message: - "ArcOS was unable to create the share you requested. You might already have the maximum amount of shares in your account.", + message: `ArcOS was unable to create the share you requested. ${createResult.errorMessage ?? "An unknown error occurred while attempting to create the share. Please contact support."}`, buttons: [BTN_OKAY_SUG], image: "ErrorIcon", sound: "arcos.dialog.error", @@ -52,7 +51,7 @@ export class ShareCreateGuiRuntime extends AppProcess implements IShareCreateGui return; } - const drive = await this.shares.mountShareById(result._id); // Mount the created share + const drive = await this.shares.mountShareById(createResult.result!._id); // Mount the created share // NOTE: The user daemon automatically mounts owned shares upon login, so we don't have to add it to startup diff --git a/src/apps/components/sharemgmtgui/ShareMgmtGui/Overlays/ChangePassword.svelte b/src/apps/components/sharemgmtgui/ShareMgmtGui/Overlays/ChangePassword.svelte index ddeaee3b2..3f4239a40 100755 --- a/src/apps/components/sharemgmtgui/ShareMgmtGui/Overlays/ChangePassword.svelte +++ b/src/apps/components/sharemgmtgui/ShareMgmtGui/Overlays/ChangePassword.svelte @@ -28,15 +28,15 @@ } const shares = Daemon?.serviceHost?.getService("ShareMgmt")!; - const result = shares?.changeSharePassword(process.parentProcess.shareId, newPassword); + const result = await shares?.changeSharePassword(process.parentProcess.shareId, newPassword); process.closeWindow(); - if (!result) { + if (!result.success) { MessageBox( { title: "Change password", - message: "Failed to change your password! Something might have gone wrong on our end. Please try again later.", + message: `Failed to change the password of the share. ${result.errorMessage ?? "An unknown error occurred. Please contact support."}`, buttons: [BTN_OKAY_SUG], image: "WarningIcon", sound: "arcos.dialog.warning", diff --git a/src/apps/core/newloginapp/runtime.ts b/src/apps/core/newloginapp/runtime.ts index 83d33a673..1ba59437c 100644 --- a/src/apps/core/newloginapp/runtime.ts +++ b/src/apps/core/newloginapp/runtime.ts @@ -89,7 +89,6 @@ export class NewLoginAppRuntime extends AppProcess implements INewLoginAppRuntim } async render(args: RenderArgs) { - console.trace("what"); if (this.ServerInfo?.freshBackend) { await State.loadState("initialSetup"); return false; diff --git a/src/interfaces/services/IShareManager.ts b/src/interfaces/services/IShareManager.ts index 092a46fc0..67e93b4a8 100644 --- a/src/interfaces/services/IShareManager.ts +++ b/src/interfaces/services/IShareManager.ts @@ -1,3 +1,4 @@ +import type { ICommandResult } from "$interfaces/ICommandResult"; import type { IFilesystemDrive } from "$interfaces/IFilesystemDrive"; import type { IBaseService } from "$interfaces/IServiceHost"; import type { SharedDriveType } from "$types/server/shares"; @@ -8,9 +9,9 @@ export interface IShareManager extends IBaseService { getOwnedShares(): Promise; mountOwnedShares(): Promise; getJoinedShares(): Promise; - createShare(name: string, password: string): Promise; + createShare(name: string, password: string): Promise>; deleteShare(shareId: string): Promise; - changeSharePassword(shareId: string, newPassword: string): Promise; + changeSharePassword(shareId: string, newPassword: string): Promise; renameShare(shareId: string, newName: string): Promise; joinShare( username: string, diff --git a/src/ts/servicehost/services/ShareMgmt/index.ts b/src/ts/servicehost/services/ShareMgmt/index.ts index 52473ac00..2f286f22f 100644 --- a/src/ts/servicehost/services/ShareMgmt/index.ts +++ b/src/ts/servicehost/services/ShareMgmt/index.ts @@ -1,3 +1,4 @@ +import type { ICommandResult } from "$interfaces/ICommandResult"; import type { IServiceHost } from "$interfaces/IServiceHost"; import type { IShareConnector } from "$interfaces/modules/server/IShareConnector"; import type { IShareManager } from "$interfaces/services/IShareManager"; @@ -48,16 +49,16 @@ export class ShareManager extends BaseService implements IShareManager { return (await this.ShareConnector.JoinedGet()).result ?? []; } - async createShare(name: string, password: string): Promise { - return (await this.ShareConnector.Create(name, password)).result; + async createShare(name: string, password: string): Promise> { + return await this.ShareConnector.Create(name, password); } async deleteShare(shareId: string) { return (await this.ShareConnector.Delete(shareId)).success; } - async changeSharePassword(shareId: string, newPassword: string): Promise { - return (await this.ShareConnector.ChangePswdPost(shareId, newPassword)).success; + async changeSharePassword(shareId: string, newPassword: string): Promise { + return await this.ShareConnector.ChangePswdPost(shareId, newPassword); } async renameShare(shareId: string, newName: string): Promise {