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 @@
+
+
+
+
+
+ {#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..f1e410369
--- /dev/null
+++ b/src/apps/components/pswdresetwizard/runtime.ts
@@ -0,0 +1,141 @@
+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 { 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";
+
+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() {
+ 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());
+ 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/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/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}
+
+
process.DismissError()}>Okay
+ {:else if $Status.variant === LoginStatusVariant.Loading}
+
+
+ {@html $Status.content}
+
+ {:else}
+
+ {/if}
+
+ {#if $Status.variant === LoginStatusVariant.None}
+
+
+
+ process.PerformRestart()}>
+ process.PerformShutdown()}>
+
+ {/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..638b9fab9
--- /dev/null
+++ b/src/apps/core/newloginapp/NewLogin/NewLoginForm.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+ process.ForgotPassword()}>Forgot password?
+
+{#if !$State.selectedUser && !ServerInfo?.disableRegistration}
+ process.CreateUser()}>No account?
+{/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)}
+
+
process.SelectUser(user.userId)}>
+
+
+
{user.displayName}
+
{user.administrator ? "Administrator" : "ArcOS User"}
+
+
+
process.RemoveUser(user.userId)}
+ >
+
+ {/each}
+
+
process.SelectUser("")}>
+
+
+
Add user...
+
+
+
+
+{/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..1ba59437c
--- /dev/null
+++ b/src/apps/core/newloginapp/runtime.ts
@@ -0,0 +1,554 @@
+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";
+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) {
+ 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");
+ }
+
+ 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
+
+ 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/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/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/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
new file mode 100644
index 000000000..48471fe2f
--- /dev/null
+++ b/src/interfaces/runtimes/INewLoginAppRuntime.ts
@@ -0,0 +1,38 @@
+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;
+ ForgotPassword(): 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/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/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/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/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/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/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 {
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",
])
);
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