Skip to content

Commit 49e54df

Browse files
committed
feat: add login
1 parent 51ded97 commit 49e54df

File tree

10 files changed

+748
-10
lines changed

10 files changed

+748
-10
lines changed

.npmrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
strict-peer-dependencies=false

components/auth/package.json

+6-5
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414
},
1515
"private": true,
1616
"dependencies": {
17-
"revolt.js": "workspace:^6.0.18",
18-
"solid-styled-components": "^0.28.4",
19-
"solid-icons": "^1.0.1",
20-
"@solidjs/router": "^0.4.3",
17+
"@revolt/client": "workspace:^1.0.0",
2118
"@revolt/i18n": "workspace:^1.0.0",
2219
"@revolt/ui": "workspace:^1.0.0",
20+
"@solidjs/router": "^0.4.3",
21+
"revolt.js": "workspace:^6.0.18",
2322
"solid-hcaptcha": "^0.2.5",
24-
"solid-js": "^1.5.1"
23+
"solid-icons": "^1.0.1",
24+
"solid-js": "^1.5.1",
25+
"solid-styled-components": "^0.28.4"
2526
}
2627
}
+61-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,63 @@
1+
import { clientController } from "@revolt/client";
2+
import HCaptcha, { HCaptchaFunctions } from "solid-hcaptcha";
3+
import { createSignal, Show } from "solid-js";
4+
15
export default function FlowLogin() {
2-
return <div>login</div>;
6+
let hcaptcha: HCaptchaFunctions | undefined;
7+
8+
const [email, setEmail] = createSignal("");
9+
const [password, setPassword] = createSignal("");
10+
11+
const [error, setError] = createSignal<string | undefined>();
12+
13+
const login = async () => {
14+
if (!email() || !password()) {
15+
setError("no email or password");
16+
return;
17+
}
18+
19+
if (!hcaptcha) {
20+
setError("hCaptcha has not loaded");
21+
return;
22+
}
23+
24+
try {
25+
const response = await hcaptcha.execute();
26+
27+
try {
28+
await clientController.login({
29+
email: email(),
30+
password: password(),
31+
captcha: response!.response,
32+
});
33+
} catch (err) {
34+
setError("login failed");
35+
}
36+
} catch (err) {
37+
setError("hCaptcha cancelled or failed");
38+
}
39+
};
40+
41+
return (
42+
<div>
43+
<p>login</p>
44+
<input
45+
placeholder="email"
46+
onInput={(e) => setEmail(e.currentTarget.value)}
47+
/>
48+
<input
49+
placeholder="password"
50+
onInput={(e) => setPassword(e.currentTarget.value)}
51+
/>
52+
<button onClick={login}>login</button>
53+
<Show when={error()}>
54+
<span>error: {error()}</span>
55+
</Show>
56+
<HCaptcha
57+
sitekey="3daae85e-09ab-4ff6-9f24-e8f4f335e433"
58+
onLoad={(instance) => (hcaptcha = instance)}
59+
size="invisible"
60+
/>
61+
</div>
62+
);
363
}

components/client/Controller.ts

+304
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { detect } from "detect-browser";
2+
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
3+
import { API, Client, Nullable } from "revolt.js";
4+
5+
// import { injectController } from "../../lib/window";
6+
7+
// import { state } from "../../mobx/State";
8+
// import Auth from "../../mobx/stores/Auth";
9+
10+
// import { resetMemberSidebarFetched } from "../../components/navigation/right/MemberSidebar";
11+
// import { modalController } from "../modals/ModalController";
12+
// import { takeError } from "./jsx/error";
13+
14+
import Session, { SessionPrivate } from "./Session";
15+
16+
/**
17+
* Controls the lifecycles of clients
18+
*/
19+
export default class ClientController {
20+
/**
21+
* API client
22+
*/
23+
private apiClient: Client;
24+
25+
/**
26+
* Server configuration
27+
*/
28+
private configuration: API.RevoltConfig | null;
29+
30+
/**
31+
* Map of user IDs to sessions
32+
*/
33+
private sessions: ObservableMap<string, Session>;
34+
35+
/**
36+
* User ID of active session
37+
*/
38+
private current: Nullable<string>;
39+
40+
constructor() {
41+
this.apiClient = new Client({
42+
apiURL: import.meta.env.VITE_API_URL,
43+
});
44+
45+
// ! FIXME: loop until success infinitely
46+
this.apiClient
47+
.fetchConfiguration()
48+
.then(() => (this.configuration = this.apiClient.configuration!));
49+
50+
this.configuration = null;
51+
this.sessions = new ObservableMap();
52+
this.current = null;
53+
54+
makeAutoObservable(this);
55+
56+
this.login = this.login.bind(this);
57+
this.logoutCurrent = this.logoutCurrent.bind(this);
58+
59+
// Inject globally
60+
// TODO injectController("client", this);
61+
}
62+
63+
pickNextSession() {
64+
this.switchAccount(
65+
this.current ?? this.sessions.keys().next().value ?? null,
66+
);
67+
}
68+
69+
/**
70+
* Hydrate sessions and start client lifecycles.
71+
* @param auth Authentication store
72+
*/
73+
// TODO
74+
/* hydrate(auth: Auth) {
75+
for (const entry of auth.getAccounts()) {
76+
this.addSession(entry, "existing");
77+
}
78+
79+
this.pickNextSession();
80+
} */
81+
82+
/**
83+
* Get the currently selected session
84+
* @returns Active Session
85+
*/
86+
getActiveSession() {
87+
return this.sessions.get(this.current!);
88+
}
89+
90+
/**
91+
* Get the currently ready client
92+
* @returns Ready Client
93+
*/
94+
getReadyClient() {
95+
const session = this.getActiveSession();
96+
return session && session.ready ? session.client! : undefined;
97+
}
98+
99+
/**
100+
* Get an unauthenticated instance of the Revolt.js Client
101+
* @returns API Client
102+
*/
103+
getAnonymousClient() {
104+
return this.apiClient;
105+
}
106+
107+
/**
108+
* Get the next available client (either from session or API)
109+
* @returns Revolt.js Client
110+
*/
111+
getAvailableClient() {
112+
return this.getActiveSession()?.client ?? this.apiClient;
113+
}
114+
115+
/**
116+
* Fetch server configuration
117+
* @returns Server Configuration
118+
*/
119+
getServerConfig() {
120+
return this.configuration;
121+
}
122+
123+
/**
124+
* Check whether we are logged in right now
125+
* @returns Whether we are logged in
126+
*/
127+
isLoggedIn() {
128+
return this.current !== null;
129+
}
130+
131+
/**
132+
* Check whether we are currently ready
133+
* @returns Whether we are ready to render
134+
*/
135+
isReady() {
136+
return this.getActiveSession()?.ready;
137+
}
138+
139+
/**
140+
* Start a new client lifecycle
141+
* @param entry Session Information
142+
* @param knowledge Whether the session is new or existing
143+
*/
144+
addSession(
145+
entry: { session: SessionPrivate; apiUrl?: string },
146+
knowledge: "new" | "existing",
147+
) {
148+
const user_id = entry.session.user_id!;
149+
150+
const session = new Session();
151+
this.sessions.set(user_id, session);
152+
this.pickNextSession();
153+
154+
session
155+
.emit({
156+
action: "LOGIN",
157+
session: entry.session,
158+
apiUrl: entry.apiUrl,
159+
configuration: this.configuration!,
160+
knowledge,
161+
})
162+
.catch((err) => {
163+
// TODO
164+
/* const error = takeError(err);
165+
if (error === "Forbidden" || error === "Unauthorized") {
166+
this.sessions.delete(user_id);
167+
this.current = null;
168+
this.pickNextSession();
169+
state.auth.removeSession(user_id);
170+
modalController.push({ type: "signed_out" });
171+
session.destroy();
172+
} else {
173+
modalController.push({
174+
type: "error",
175+
error,
176+
});
177+
} */
178+
});
179+
}
180+
181+
/**
182+
* Login given a set of credentials
183+
* @param credentials Credentials
184+
*/
185+
async login(credentials: API.DataLogin) {
186+
const browser = detect();
187+
188+
// Generate a friendly name for this browser
189+
let friendly_name;
190+
if (browser) {
191+
let { name } = browser;
192+
const { os } = browser;
193+
let isiPad;
194+
// TODO window.isNative
195+
if (false) {
196+
friendly_name = `Revolt Desktop on ${os}`;
197+
} else {
198+
if (name === "ios") {
199+
name = "safari";
200+
} else if (name === "fxios") {
201+
name = "firefox";
202+
} else if (name === "crios") {
203+
name = "chrome";
204+
}
205+
if (os === "Mac OS" && navigator.maxTouchPoints > 0)
206+
isiPad = true;
207+
friendly_name = `${name} on ${isiPad ? "iPadOS" : os}`;
208+
}
209+
} else {
210+
friendly_name = "Unknown Device";
211+
}
212+
213+
// Try to login with given credentials
214+
let session = await this.apiClient.api.post("/auth/session/login", {
215+
...credentials,
216+
friendly_name,
217+
});
218+
219+
// Prompt for MFA verificaiton if necessary
220+
if (session.result === "MFA") {
221+
const { allowed_methods } = session;
222+
while (session.result === "MFA") {
223+
const mfa_response: API.MFAResponse | undefined =
224+
await new Promise((callback) =>
225+
undefined
226+
// TODO
227+
/* modalController.push({
228+
type: "mfa_flow",
229+
state: "unknown",
230+
available_methods: allowed_methods,
231+
callback,
232+
}), */
233+
);
234+
235+
if (typeof mfa_response === "undefined") {
236+
break;
237+
}
238+
239+
try {
240+
session = await this.apiClient.api.post(
241+
"/auth/session/login",
242+
{
243+
mfa_response,
244+
mfa_ticket: session.ticket,
245+
friendly_name,
246+
},
247+
);
248+
} catch (err) {
249+
console.error("Failed login:", err);
250+
}
251+
}
252+
253+
if (session.result === "MFA") {
254+
throw "Cancelled";
255+
}
256+
}
257+
258+
// Start client lifecycle
259+
this.addSession(
260+
{
261+
session,
262+
},
263+
"new",
264+
);
265+
}
266+
267+
/**
268+
* Log out of a specific user session
269+
* @param user_id Target User ID
270+
*/
271+
logout(user_id: string) {
272+
const session = this.sessions.get(user_id);
273+
if (session) {
274+
if (user_id === this.current) {
275+
this.current = null;
276+
}
277+
278+
this.sessions.delete(user_id);
279+
this.pickNextSession();
280+
session.destroy();
281+
}
282+
}
283+
284+
/**
285+
* Logout of the current session
286+
*/
287+
logoutCurrent() {
288+
if (this.current) {
289+
this.logout(this.current);
290+
}
291+
}
292+
293+
/**
294+
* Switch to another user session
295+
* @param user_id Target User ID
296+
*/
297+
switchAccount(user_id: string) {
298+
this.current = user_id;
299+
300+
// This will allow account switching to work more seamlessly,
301+
// maybe it'll be properly / fully implemented at some point.
302+
// TODO resetMemberSidebarFetched();
303+
}
304+
}

0 commit comments

Comments
 (0)