feat: URL navigation in auth (#1603)

* bump to react 18 and install react-router-dom

* Upgrade to react 18 root

* update vite

* add cs api's

* convert state/auth to ts

* add client config context

* add auto discovery context

* add spec version context

* add auth flow context

* add background dot pattern css

* add promise utils

* init url based routing

* update auth route server path as effect

* add auth server hook

* always use server from discovery info in context

* login - WIP

* upgrade jotai to v2

* add atom with localStorage util

* add multi account sessions atom

* add default IGNORE res to auto discovery

* add error type in async callback hook

* handle password login error

* fix async callback hook

* allow password login

* Show custom server not allowed error in mxId login

* add sso login component

* add token login

* fix hardcoded m.login.password in login func

* update server input on url change

* Improve sso login labels

* update folds

* fix async callback batching state update in safari

* wrap async callback set state in queueMicrotask

* wip

* wip - register

* arrange auth file structure

* add error codes

* extract filed error component form password login

* add register util function

* handle register flow - WIP

* update unsupported auth flow method reasons

* improve password input styles

* Improve UIA flow next stage calculation
complete stages can have any order so we will look for first stage which is not in completed

* process register UIA flow stages

* Extract register UIA stages component

* improve register error messages

* add focus trap & step count in UIA stages

* add reset password path and path utils

* add path with origin hook

* fix sso redirect url

* rename register token query param to token

* restyle auth screen header

* add reset password component - WIP

* add reset password form

* add netlify rewrites

* fix netlify file indentation

* test netlify redirect

* fix vite to include netlify toml

* add more netlify redirects

* add splat to public and assets path

* fix vite base name

* add option to use hash router in config and remove appVersion

* add splash screen component

* add client config loading and error screen

* fix server picker bug

* fix reset password email input type

* make auth page small screen responsive

* fix typo in reset password screen
This commit is contained in:
Ajay Bura 2024-01-21 23:50:56 +11:00 committed by GitHub
parent bb88eb7154
commit 20db27fa7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 4772 additions and 543 deletions

View file

@ -1,28 +1,26 @@
import { useAtomValue, WritableAtom } from 'jotai';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
import { compareRoomsEqual, RoomsAction } from '../utils';
import { MDirectAction } from '../mDirectList';
import { compareRoomsEqual } from '../utils';
import { mDirectAtom } from '../mDirectList';
import { allInvitesAtom } from '../inviteList';
export const useSpaceInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>
) => {
export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useRoomInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
invitesAtom: typeof allInvitesAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
@ -32,15 +30,15 @@ export const useRoomInvites = (
),
[mx, mDirects]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useDirectInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
invitesAtom: typeof allInvitesAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
@ -48,16 +46,13 @@ export const useDirectInvites = (
),
[mx, mDirects]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useUnsupportedInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>
) => {
export const useUnsupportedInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};

View file

@ -1,54 +1,52 @@
import { useAtomValue, WritableAtom } from 'jotai';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
import { compareRoomsEqual, RoomsAction } from '../utils';
import { MDirectAction } from '../mDirectList';
import { compareRoomsEqual } from '../utils';
import { mDirectAtom } from '../mDirectList';
import { allRoomsAtom } from '../roomList';
export const useSpaces = (mx: MatrixClient, allRoomsAtom: WritableAtom<string[], RoomsAction>) => {
export const useSpaces = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};
export const useRooms = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
roomsAtom: typeof allRoomsAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
[mx, mDirects]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};
export const useDirects = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
roomsAtom: typeof allRoomsAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
[mx, mDirects]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};
export const useUnsupportedRooms = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>
) => {
export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};

View file

@ -1,16 +1,16 @@
import { atom, useAtomValue, useSetAtom, WritableAtom } from 'jotai';
import { SetAtom } from 'jotai/core/atom';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { useMemo } from 'react';
import { Settings } from '../settings';
import { Settings, settingsAtom as sAtom } from '../settings';
export const useSetSetting = <K extends keyof Settings>(
settingsAtom: WritableAtom<Settings, Settings>,
key: K
) => {
export type SettingSetter<K extends keyof Settings> =
| Settings[K]
| ((s: Settings[K]) => Settings[K]);
export const useSetSetting = <K extends keyof Settings>(settingsAtom: typeof sAtom, key: K) => {
const setterAtom = useMemo(
() =>
atom<null, Settings[K] | ((s: Settings[K]) => Settings[K])>(null, (get, set, value) => {
atom<null, [SettingSetter<K>], undefined>(null, (get, set, value) => {
const s = { ...get(settingsAtom) };
s[key] = typeof value === 'function' ? value(s[key]) : value;
set(settingsAtom, s);
@ -22,9 +22,9 @@ export const useSetSetting = <K extends keyof Settings>(
};
export const useSetting = <K extends keyof Settings>(
settingsAtom: WritableAtom<Settings, Settings>,
settingsAtom: typeof sAtom,
key: K
): [Settings[K], SetAtom<Settings[K] | ((s: Settings[K]) => Settings[K]), void>] => {
): [Settings[K], ReturnType<typeof useSetSetting<K>>] => {
const selector = useMemo(() => (s: Settings) => s[key], [key]);
const setting = useAtomValue(selectAtom(settingsAtom, selector));

View file

@ -5,7 +5,7 @@ import { Membership } from '../../types/matrix/room';
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
const baseRoomsAtom = atom<string[]>([]);
export const allInvitesAtom = atom<string[], RoomsAction>(
export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
(get) => get(baseRoomsAtom),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
@ -22,7 +22,7 @@ export const allInvitesAtom = atom<string[], RoomsAction>(
export const useBindAllInvitesAtom = (
mx: MatrixClient,
allRooms: WritableAtom<string[], RoomsAction>
allRooms: WritableAtom<string[], [RoomsAction], undefined>
) => {
useBindRoomsWithMembershipsAtom(
mx,

View file

@ -12,7 +12,7 @@ export type ListAction<T> =
export const createListAtom = <T>() => {
const baseListAtom = atom<T[]>([]);
return atom<T[], ListAction<T>>(
return atom<T[], [ListAction<T>], undefined>(
(get) => get(baseListAtom),
(get, set, action) => {
const items = get(baseListAtom);

View file

@ -1,4 +1,4 @@
import { atom, useSetAtom, WritableAtom } from 'jotai';
import { atom, useSetAtom } from 'jotai';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
@ -10,17 +10,14 @@ export type MDirectAction = {
};
const baseMDirectAtom = atom(new Set<string>());
export const mDirectAtom = atom<Set<string>, MDirectAction>(
export const mDirectAtom = atom<Set<string>, [MDirectAction], undefined>(
(get) => get(baseMDirectAtom),
(get, set, action) => {
set(baseMDirectAtom, action.rooms);
}
);
export const useBindMDirectAtom = (
mx: MatrixClient,
mDirect: WritableAtom<Set<string>, MDirectAction>
) => {
export const useBindMDirectAtom = (mx: MatrixClient, mDirect: typeof mDirectAtom) => {
const setMDirect = useSetAtom(mDirect);
useEffect(() => {

View file

@ -1,4 +1,4 @@
import { atom, WritableAtom, useSetAtom } from 'jotai';
import { atom, useSetAtom } from 'jotai';
import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { MuteChanges } from '../../types/matrix/room';
@ -21,7 +21,7 @@ export const muteChangesAtom = atom<MuteChanges>({
});
const baseMutedRoomsAtom = atom(new Set<string>());
export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
(get) => get(baseMutedRoomsAtom),
(get, set, action) => {
const mutedRooms = new Set([...get(mutedRoomsAtom)]);
@ -45,10 +45,7 @@ export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
}
);
export const useBindMutedRoomsAtom = (
mx: MatrixClient,
mutedAtom: WritableAtom<Set<string>, MutedRoomsUpdate>
) => {
export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
const setMuted = useSetAtom(mutedAtom);
useEffect(() => {

View file

@ -1,11 +1,11 @@
import { atom, WritableAtom } from 'jotai';
import { atom } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { Membership } from '../../types/matrix/room';
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
const baseRoomsAtom = atom<string[]>([]);
export const allRoomsAtom = atom<string[], RoomsAction>(
export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
(get) => get(baseRoomsAtom),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
@ -19,10 +19,7 @@ export const allRoomsAtom = atom<string[], RoomsAction>(
});
}
);
export const useBindAllRoomsAtom = (
mx: MatrixClient,
allRooms: WritableAtom<string[], RoomsAction>
) => {
export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
useBindRoomsWithMembershipsAtom(
mx,
allRooms,

View file

@ -1,5 +1,5 @@
import produce from 'immer';
import { atom, useSetAtom, WritableAtom } from 'jotai';
import { atom, useSetAtom } from 'jotai';
import {
ClientEvent,
MatrixClient,
@ -34,7 +34,7 @@ export type RoomToParentsAction =
};
const baseRoomToParents = atom<RoomToParents>(new Map());
export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
(get) => get(baseRoomToParents),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
@ -69,7 +69,7 @@ export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
export const useBindRoomToParentsAtom = (
mx: MatrixClient,
roomToParents: WritableAtom<RoomToParents, RoomToParentsAction>
roomToParents: typeof roomToParentsAtom
) => {
const setRoomToParents = useSetAtom(roomToParents);

View file

@ -1,5 +1,5 @@
import produce from 'immer';
import { atom, useSetAtom, PrimitiveAtom, WritableAtom, useAtomValue } from 'jotai';
import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { useEffect } from 'react';
@ -82,7 +82,7 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
};
const baseRoomToUnread = atom<RoomToUnread>(new Map());
export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
(get) => get(baseRoomToUnread),
(get, set, action) => {
if (action.type === 'RESET') {
@ -127,7 +127,7 @@ export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
export const useBindRoomToUnreadAtom = (
mx: MatrixClient,
unreadAtom: WritableAtom<RoomToUnread, RoomToUnreadAction>,
unreadAtom: typeof roomToUnreadAtom,
muteChangesAtom: PrimitiveAtom<MuteChanges>
) => {
const setUnreadAtom = useSetAtom(unreadAtom);

129
src/app/state/sessions.ts Normal file
View file

@ -0,0 +1,129 @@
import { atom } from 'jotai';
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
export type Session = {
baseUrl: string;
userId: string;
deviceId: string;
accessToken: string;
expiresInMs?: number;
refreshToken?: string;
fallbackSdkStores?: boolean;
};
export type Sessions = Session[];
export type SessionStoreName = {
sync: string;
crypto: string;
};
/**
* Migration code for old session
*/
const FALLBACK_STORE_NAME: SessionStoreName = {
sync: 'web-sync-store',
crypto: 'crypto-store',
} as const;
const removeFallbackSession = () => {
localStorage.removeItem('cinny_hs_base_url');
localStorage.removeItem('cinny_user_id');
localStorage.removeItem('cinny_device_id');
localStorage.removeItem('cinny_access_token');
};
const getFallbackSession = (): Session | undefined => {
const baseUrl = localStorage.getItem('cinny_hs_base_url');
const userId = localStorage.getItem('cinny_user_id');
const deviceId = localStorage.getItem('cinny_device_id');
const accessToken = localStorage.getItem('cinny_access_token');
if (baseUrl && userId && deviceId && accessToken) {
const session: Session = {
baseUrl,
userId,
deviceId,
accessToken,
fallbackSdkStores: true,
};
return session;
}
return undefined;
};
/**
* End of migration code for old session
*/
export const getSessionStoreName = (session: Session): SessionStoreName => {
if (session.fallbackSdkStores) {
return FALLBACK_STORE_NAME;
}
return {
sync: `sync${session.userId}`,
crypto: `crypto${session.userId}`,
};
};
export const MATRIX_SESSIONS_KEY = 'matrixSessions';
const baseSessionsAtom = atomWithLocalStorage<Sessions>(
MATRIX_SESSIONS_KEY,
(key) => {
const defaultSessions: Sessions = [];
const sessions = getLocalStorageItem(key, defaultSessions);
// Before multi account support session was stored
// as multiple item in local storage.
// So we need these migration code.
const fallbackSession = getFallbackSession();
if (fallbackSession) {
removeFallbackSession();
sessions.push(fallbackSession);
setLocalStorageItem(key, sessions);
}
return sessions;
},
(key, value) => {
setLocalStorageItem(key, value);
}
);
export type SessionsAction =
| {
type: 'PUT';
session: Session;
}
| {
type: 'DELETE';
session: Session;
};
export const sessionsAtom = atom<Sessions, [SessionsAction], undefined>(
(get) => get(baseSessionsAtom),
(get, set, action) => {
if (action.type === 'PUT') {
const sessions = [...get(baseSessionsAtom)];
const sessionIndex = sessions.findIndex(
(session) => session.userId === action.session.userId
);
if (sessionIndex === -1) {
sessions.push(action.session);
} else {
sessions.splice(sessionIndex, 1, action.session);
}
set(baseSessionsAtom, sessions);
return;
}
if (action.type === 'DELETE') {
const sessions = get(baseSessionsAtom).filter(
(session) => session.userId !== action.session.userId
);
set(baseSessionsAtom, sessions);
}
}
);

View file

@ -64,7 +64,7 @@ export const setSettings = (settings: Settings) => {
};
const baseSettings = atom<Settings>(getSettings());
export const settingsAtom = atom<Settings, Settings>(
export const settingsAtom = atom<Settings, [Settings], undefined>(
(get) => get(baseSettings),
(get, set, update) => {
set(baseSettings, update);

View file

@ -14,7 +14,7 @@ type TabToRoomAction = {
};
const baseTabToRoom = atom<TabToRoom>(new Map());
export const tabToRoomAtom = atom<TabToRoom, TabToRoomAction>(
export const tabToRoomAtom = atom<TabToRoom, [TabToRoomAction], undefined>(
(get) => get(baseTabToRoom),
(get, set, action) => {
if (action.type === 'PUT') {

View file

@ -23,7 +23,11 @@ export type IRoomIdToTypingMembersAction =
};
const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(new Map());
export const roomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers, IRoomIdToTypingMembersAction>(
export const roomIdToTypingMembersAtom = atom<
IRoomIdToTypingMembers,
[IRoomIdToTypingMembersAction],
undefined
>(
(get) => get(baseRoomIdToTypingMembersAtom),
(get, set, action) => {
const roomIdToTypingMembers = get(baseRoomIdToTypingMembersAtom);

View file

@ -57,7 +57,7 @@ export const createUploadAtom = (file: TUploadContent) => {
file,
status: UploadStatus.Idle,
});
return atom<Upload, UploadAtomAction>(
return atom<Upload, [UploadAtomAction], undefined>(
(get) => get(baseUploadAtom),
(get, set, update) => {
const uploadState = get(baseUploadAtom);

View file

@ -15,7 +15,7 @@ export type RoomsAction =
export const useBindRoomsWithMembershipsAtom = (
mx: MatrixClient,
roomsAtom: WritableAtom<string[], RoomsAction>,
roomsAtom: WritableAtom<string[], [RoomsAction], undefined>,
memberships: Membership[]
) => {
const setRoomsAtom = useSetAtom(roomsAtom);

View file

@ -0,0 +1,51 @@
import { atom } from 'jotai';
export const getLocalStorageItem = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key);
if (item === null) return defaultValue;
if (item === 'undefined') return undefined as T;
try {
return JSON.parse(item) as T;
} catch {
return defaultValue;
}
};
export const setLocalStorageItem = <T>(key: string, value: T) => {
localStorage.setItem(key, JSON.stringify(value));
};
export type GetLocalStorageItem<T> = (key: string) => T;
export type SetLocalStorageItem<T> = (key: string, value: T) => void;
export const atomWithLocalStorage = <T>(
key: string,
getItem: GetLocalStorageItem<T>,
setItem: SetLocalStorageItem<T>
) => {
const value = getItem(key);
const baseAtom = atom<T>(value);
baseAtom.onMount = (setAtom) => {
const handleChange = (evt: StorageEvent) => {
if (evt.key !== key) return;
setAtom(getItem(key));
};
window.addEventListener('storage', handleChange);
return () => {
window.removeEventListener('storage', handleChange);
};
};
const localStorageAtom = atom<T, [T], undefined>(
(get) => get(baseAtom),
(get, set, newValue) => {
set(baseAtom, newValue);
setItem(key, newValue);
}
);
return localStorageAtom;
};