Migrate to PocketBase (#801)

This commit is contained in:
Piotr Rogowski 2022-10-17 22:28:33 +02:00 committed by GitHub
parent e4cfe46c53
commit 1712e5c8bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1159 additions and 1813 deletions

4
.env
View File

@ -1,10 +1,10 @@
NPM_GITHUB_TOKEN=
VITE_ENVIRONMENT=development
VITE_WEB_URL=http://localhost:3000
VITE_WEB_URL=http://localhost:5173
VITE_SENTRY_DSN=
VITE_GTM_ID=
# TODO: remove this later
VITE_CDN_URL=
VITE_APPWRITE_ENDPOINT=
VITE_POCKETBASE_API_URL=

View File

@ -1,8 +1,8 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"cSpell.words": [
"Appwrite",
"kbar",
"pocketbase",
"prefs",
"vite",
"vitejs"

866
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,13 +24,13 @@
"@reduxjs/toolkit": "^1.8.6",
"@sentry/react": "^7.15.0",
"@sentry/tracing": "^7.15.0",
"antd": "^4.23.5",
"appwrite": "10.1.0",
"antd": "^4.23.6",
"kbar": "^0.1.0-beta.36",
"lodash.debounce": "^4.0.8",
"mlg-converter": "^0.5.1",
"nanoid": "^4.0.0",
"pako": "^2.0.4",
"pocketbase": "^0.7.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-ga4": "^1.4.1",
@ -45,7 +45,7 @@
"devDependencies": {
"@hyper-tuner/eslint-config": "^0.1.6",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^18.8.5",
"@types/node": "^18.11.0",
"@types/pako": "^2.0.0",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
@ -64,7 +64,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"less": "^4.1.3",
"prettier": "^2.7.1",
"rollup-plugin-visualizer": "^5.8.2",
"rollup-plugin-visualizer": "^5.8.3",
"typescript": "^4.8.4"
}
}

View File

@ -0,0 +1,39 @@
// Generated using pocketbase-typegen
export enum Collections {
Profiles = 'profiles',
Tunes = 'tunes',
}
export type ProfilesRecord = {
userId: string;
username: string;
avatar?: string;
}
export type TunesRecord = {
user: string;
userProfile: string;
tuneId: string;
signature: string;
vehicleName: string;
engineMake: string;
engineCode: string;
displacement: number;
cylindersCount: number;
aspiration: string;
compression?: number;
fuel?: string;
ignition?: string;
injectorsSize?: number;
year?: number;
hp?: number;
stockHp?: number;
readme: string;
textSearch: string;
visibility: string;
tuneFile: string;
customIniFile?: string;
logFiles?: string[];
toothLogFiles?: string[];
}

View File

@ -3,6 +3,7 @@ import {
Route,
useMatch,
useNavigate,
generatePath,
} from 'react-router-dom';
import {
Layout,
@ -33,6 +34,7 @@ import {
import useDb from './hooks/useDb';
import Info from './pages/Info';
import Hub from './pages/Hub';
import { FormRoles } from './pages/auth/Login';
import 'react-perfect-scrollbar/dist/css/styles.css';
import './css/App.less';
@ -46,11 +48,10 @@ const Diagnose = lazy(() => import('./pages/Diagnose'));
const Upload = lazy(() => import('./pages/Upload'));
const Login = lazy(() => import('./pages/auth/Login'));
const Profile = lazy(() => import('./pages/auth/Profile'));
const SignUp = lazy(() => import('./pages/auth/SignUp'));
const ResetPassword = lazy(() => import('./pages/auth/ResetPassword'));
const MagicLinkConfirmation = lazy(() => import('./pages/auth/MagicLinkConfirmation'));
const EmailVerification = lazy(() => import('./pages/auth/EmailVerification'));
const ResetPasswordConfirmation = lazy(() => import('./pages/auth/ResetPasswordConfirmation'));
const EmailVerification = lazy(() => import('./pages/auth/EmailVerification'));
const OauthCallback = lazy(() => import('./pages/auth/OauthCallback'));
const { Content } = Layout;
@ -64,27 +65,9 @@ const mapStateToProps = (state: AppState) => ({
const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: NavigationState, tuneData: TuneDataState }) => {
const margin = ui.sidebarCollapsed ? 80 : 250;
const { getTune } = useDb();
const searchParams = new URLSearchParams(window.location.search);
const redirectPage = searchParams.get('redirectPage');
const [isLoading, setIsLoading] = useState(false);
const { getBucketId } = useDb();
const navigate = useNavigate();
// TODO: refactor this
switch (redirectPage) {
case Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION:
window.location.href = `/#${Routes.MAGIC_LINK_CONFIRMATION}?${searchParams.toString()}`;
break;
case Routes.REDIRECT_PAGE_EMAIL_VERIFICATION:
window.location.href = `/#${Routes.EMAIL_VERIFICATION}?${searchParams.toString()}`;
break;
case Routes.REDIRECT_PAGE_RESET_PASSWORD:
window.location.href = `/#${Routes.RESET_PASSWORD_CONFIRMATION}?${searchParams.toString()}`;
break;
default:
break;
}
// const [lastDialogPath, setLastDialogPath] = useState<string|null>();
// const lastDialogPath = storageGetSync('lastDialog');
@ -92,11 +75,23 @@ const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: Navigation
const tuneId = tunePathMatch?.params.tuneId;
useEffect(() => {
// Handle external redirects (oauth, etc)
// TODO: refactor this
const searchParams = new URLSearchParams(window.location.search);
const redirectPage = searchParams.get('redirect');
switch (redirectPage) {
case Routes.REDIRECT_PAGE_OAUTH_CALLBACK:
window.location.href = `/#${generatePath(Routes.OAUTH_CALLBACK, { provider: searchParams.get('provider')! })}?${searchParams.toString()}`;
break;
default:
break;
}
if (tuneId) {
// clear out last state
if (tuneData && tuneId !== tuneData.tuneId) {
setIsLoading(true);
loadTune(null, '');
loadTune(null);
store.dispatch({ type: 'tuneData/load', payload: null });
setIsLoading(false);
}
@ -108,10 +103,8 @@ const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: Navigation
return;
}
getBucketId(tune.userId).then((bucketId) => {
loadTune(tune!, bucketId);
});
store.dispatch({ type: 'tuneData/load', payload: tune });
loadTune(tune!);
store.dispatch({ type: 'tuneData/load', payload: JSON.parse(JSON.stringify(tune)) });
});
store.dispatch({ type: 'navigation/tuneId', payload: tuneId });
@ -158,14 +151,14 @@ const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: Navigation
<Route path={Routes.TUNE_DIAGNOSE} element={<ContentFor marginLeft={margin} element={<Diagnose />} />} />
<Route path={`${Routes.UPLOAD}/*`} element={<ContentFor element={<Upload />} />} />
<Route path={Routes.LOGIN} element={<ContentFor element={<Login />} />} />
<Route path={Routes.LOGIN} element={<ContentFor element={<Login formRole={FormRoles.LOGIN} />} />} />
<Route path={Routes.SIGN_UP} element={<ContentFor element={<Login formRole={FormRoles.SING_UP} />} />} />
<Route path={Routes.PROFILE} element={<ContentFor element={<Profile />} />} />
<Route path={Routes.SIGN_UP} element={<ContentFor element={<SignUp />} />} />
<Route path={Routes.RESET_PASSWORD} element={<ContentFor element={<ResetPassword />} />} />
<Route path={Routes.MAGIC_LINK_CONFIRMATION} element={<ContentFor element={<MagicLinkConfirmation />} />} />
<Route path={Routes.EMAIL_VERIFICATION} element={<ContentFor element={<EmailVerification />} />} />
<Route path={Routes.RESET_PASSWORD_CONFIRMATION} element={<ContentFor element={<ResetPasswordConfirmation />} />} />
<Route path={Routes.OAUTH_CALLBACK} element={<ContentFor element={<OauthCallback />} />} />
</ReactRoutes>
<Result status="warning" title="Page not found" style={{ marginTop: 50 }} />
</Layout>

View File

@ -1,27 +0,0 @@
import {
Account,
Client,
Databases,
Functions,
Storage,
} from 'appwrite';
import { fetchEnv } from './utils/env';
const client = new Client();
client
.setEndpoint(fetchEnv('VITE_APPWRITE_ENDPOINT'))
.setProject('hyper-tuner-api');
const account = new Account(client);
const databases = new Databases(client);
const storage = new Storage(client);
const functions = new Functions(client);
export {
client,
account,
databases,
storage,
functions,
};

View File

@ -46,10 +46,7 @@ import {
} from '@hyper-tuner/types';
import { Routes } from '../routes';
import { useAuth } from '../contexts/AuthContext';
import {
logOutFailed,
logOutSuccessful,
} from '../pages/auth/notifications';
import { logOutSuccessful } from '../pages/auth/notifications';
import store from '../store';
import { isMac } from '../utils/env';
import {
@ -323,15 +320,11 @@ const CommandPalette = (props: CommandPaletteProps) => {
const { logout } = useAuth();
const navigate = useNavigate();
const logoutAction = useCallback(async () => {
try {
await logout();
logOutSuccessful();
} catch (error) {
console.warn(error);
logOutFailed(error as Error);
}
}, [logout]);
const logoutAction = useCallback(() => {
logout();
logOutSuccessful();
navigate(Routes.HUB);
}, [logout, navigate]);
const initialActions = [
{

View File

@ -51,10 +51,7 @@ import { isMac } from '../utils/env';
import { isToggleSidebar } from '../utils/keyboard/shortcuts';
import { Routes } from '../routes';
import { useAuth } from '../contexts/AuthContext';
import {
logOutFailed,
logOutSuccessful,
} from '../pages/auth/notifications';
import { logOutSuccessful } from '../pages/auth/notifications';
const { Header } = Layout;
const { useBreakpoint } = Grid;
@ -73,15 +70,10 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
const tabMatch = useMatch(`${Routes.TUNE_TAB}/*`);
const uploadMatch = useMatch(Routes.UPLOAD);
const hubMatch = useMatch(Routes.HUB);
const logoutClick = useCallback(async () => {
try {
navigate(Routes.HUB);
logout();
logOutSuccessful();
} catch (error) {
console.warn(error);
logOutFailed(error as Error);
}
const logoutClick = useCallback(() => {
logout();
logOutSuccessful();
navigate(Routes.HUB);
}, [logout, navigate]);
const toggleCommandPalette = useCallback(() => query.toggle(), [query]);
@ -189,7 +181,7 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
}], [currentUser, logoutClick, navigate]);
return (
<Header className="app-top-bar" style={xs ? { padding: '0 5px' } : {} }>
<Header className="app-top-bar" style={xs ? { padding: '0 5px' } : {}}>
<Row>
{tuneId ? tabs : (
<Col span={10} md={14} sm={16}>

View File

@ -1,7 +1,3 @@
import {
ID,
Models,
} from 'appwrite';
import {
createContext,
ReactNode,
@ -10,208 +6,188 @@ import {
useMemo,
useState,
} from 'react';
import { User } from 'pocketbase';
import {
account,
client,
} from '../appwrite';
import Loader from '../components/Loader';
formatError,
} from '../pocketbase';
import { buildRedirectUrl } from '../utils/url';
import { Collections } from '../@types/pocketbase-types';
import { Routes } from '../routes';
import {
buildFullUrl,
buildRedirectUrl,
} from '../utils/url';
export type SessionList = Models.SessionList;
export type LogList = Models.LogList;
export type Account = Models.Account<Models.Preferences>;
// TODO: this should be imported from pocketbase but currently is not exported
export type AuthProviderInfo = {
name: string;
state: string;
codeVerifier: string;
codeChallenge: string;
codeChallengeMethod: string;
authUrl: string;
};
// TODO: this should be imported from pocketbase but currently is not exported
export type AuthMethodsList = {
[key: string]: any;
emailPassword: boolean;
authProviders: Array<AuthProviderInfo>;
};
export enum OAuthProviders {
GOOGLE = 'google',
GITHUB = 'github',
FACEBOOK = 'facebook',
};
interface AuthValue {
currentUser: Account | null,
signUp: (email: string, password: string, username: string) => Promise<Account>,
login: (email: string, password: string) => Promise<Account>,
sendMagicLink: (email: string) => Promise<void>,
confirmMagicLink: (userId: string, secret: string) => Promise<Account>,
currentUser: User | null,
signUp: (email: string, password: string) => Promise<User>,
login: (email: string, password: string) => Promise<User>,
refreshUser: () => Promise<User | null>,
sendEmailVerification: () => Promise<void>,
confirmEmailVerification: (userId: string, secret: string) => Promise<void>,
confirmResetPassword: (userId: string, secret: string, password: string) => Promise<void>,
logout: () => Promise<void>,
confirmEmailVerification: (token: string) => Promise<void>,
confirmResetPassword: (token: string, password: string) => Promise<void>,
logout: () => void,
initResetPassword: (email: string) => Promise<void>,
googleAuth: () => Promise<void>,
githubAuth: () => Promise<void>,
facebookAuth: () => Promise<void>,
listAuthMethods: () => Promise<AuthMethodsList>,
oAuth: (provider: OAuthProviders, code: string, codeVerifier: string) => Promise<void>,
updateUsername: (username: string) => Promise<void>,
updatePassword: (password: string, oldPassword: string) => Promise<void>,
getSessions: () => Promise<SessionList>,
getLogs: () => Promise<LogList>,
}
const OAUTH_REDIRECT_URL = buildFullUrl();
const MAGIC_LINK_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION);
const EMAIL_VERIFICATION_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_EMAIL_VERIFICATION);
const RESET_PASSWORD_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_RESET_PASSWORD);
const GOOGLE_SCOPES = ['https://www.googleapis.com/auth/userinfo.email'];
const GITHUB_SCOPES = ['user:email'];
const FACEBOOK_SCOPES = ['email'];
const AuthContext = createContext<AuthValue | null>(null);
const useAuth = () => useContext<AuthValue>(AuthContext as any);
const AuthProvider = (props: { children: ReactNode }) => {
const { children } = props;
const [currentUser, setCurrentUser] = useState<Account | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const value = useMemo(() => ({
currentUser,
signUp: async (email: string, password: string, username: string) => {
signUp: async (email: string, password: string) => {
try {
await account.create(ID.unique(), email, password, username);
await account.createEmailSession(email, password);
const user = await account.get();
setCurrentUser(user);
const user = await client.users.create({
email,
password,
passwordConfirm: password,
});
client.users.requestVerification(user.email);
await client.users.authViaEmail(user.email, password);
return Promise.resolve(user);
} catch (error) {
return Promise.reject(error);
return Promise.reject(new Error(formatError(error)));
}
},
login: async (email: string, password: string) => {
try {
await account.createEmailSession(email, password);
const user = await account.get();
setCurrentUser(user);
return Promise.resolve(user);
const authResponse = await client.users.authViaEmail(email, password);
return Promise.resolve(authResponse.user);
} catch (error) {
return Promise.reject(error);
return Promise.reject(new Error(formatError(error)));
}
},
sendMagicLink: async (email: string) => {
refreshUser: async () => {
try {
await account.createMagicURLSession(ID.unique(), email, MAGIC_LINK_REDIRECT_URL);
return Promise.resolve();
const authResponse = await client.users.refresh();
return Promise.resolve(authResponse.user);
} catch (error) {
return Promise.reject(error);
}
},
confirmMagicLink: async (userId: string, secret: string) => {
try {
await account.updateMagicURLSession(userId, secret);
const user = await account.get();
setCurrentUser(user);
return Promise.resolve(user);
} catch (error) {
return Promise.reject(error);
client.authStore.clear();
return Promise.resolve(null);
}
},
sendEmailVerification: async () => {
try {
await account.createVerification(EMAIL_VERIFICATION_REDIRECT_URL);
await client.users.requestVerification(currentUser!.email);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
return Promise.reject(new Error(formatError(error)));
}
},
confirmEmailVerification: async (userId: string, secret: string) => {
confirmEmailVerification: async (token: string) => {
try {
await account.updateVerification(userId, secret);
const user = await account.get();
setCurrentUser(user);
await client.users.confirmVerification(token);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
return Promise.reject(new Error(formatError(error)));
}
},
confirmResetPassword: async (userId: string, secret: string, password: string) => {
confirmResetPassword: async (token: string, password: string) => {
try {
await account.updateRecovery(userId, secret, password, password);
await client.users.confirmPasswordReset(token, password, password);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
return Promise.reject(new Error(formatError(error)));
}
},
logout: async () => {
try {
await account.deleteSession('current');
setCurrentUser(null);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
client.authStore.clear();
},
initResetPassword: async (email: string) => {
try {
await account.createRecovery(email, RESET_PASSWORD_REDIRECT_URL);
await client.users.requestPasswordReset(email);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
return Promise.reject(new Error(formatError(error)));
}
},
googleAuth: async () => {
account.createOAuth2Session(
'google',
OAUTH_REDIRECT_URL,
OAUTH_REDIRECT_URL,
GOOGLE_SCOPES,
);
listAuthMethods: async () => {
try {
const methods = await client.users.listAuthMethods();
return Promise.resolve(methods);
} catch (error) {
return Promise.reject(new Error(formatError(error)));
}
},
githubAuth: async () => {
account.createOAuth2Session(
'github',
OAUTH_REDIRECT_URL,
OAUTH_REDIRECT_URL,
GITHUB_SCOPES,
);
},
facebookAuth: async () => {
account.createOAuth2Session(
'facebook',
OAUTH_REDIRECT_URL,
OAUTH_REDIRECT_URL,
FACEBOOK_SCOPES,
oAuth: async (provider: OAuthProviders, code: string, codeVerifier: string) => {
client.users.authViaOAuth2(
provider,
code,
codeVerifier,
buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider }),
);
},
updateUsername: async (username: string) => {
try {
await account.updateName(username);
const user = await account.get();
setCurrentUser(user);
await client.records.update(Collections.Profiles, currentUser!.profile!.id, {
username,
});
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
return Promise.reject(new Error(formatError(error)));
}
},
updatePassword: async (password: string, oldPassword: string) => {
try {
await account.updatePassword(password, oldPassword);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
getSessions: () => account.listSessions(),
getLogs: () => account.listLogs(),
}), [currentUser]);
useEffect(() => {
account.get().then((user) => {
console.info('Logged as:', user.name || 'Unknown');
setCurrentUser(user);
setIsLoading(false);
}).catch(() => {
console.info('User not logged in');
}).finally(() => setIsLoading(false));
setCurrentUser(client.authStore.model as User | null);
const unsubscribe = client.subscribe('account', (event) => {
console.info('Account event', event);
const storeUnsubscribe = client.authStore.onChange((_token, model) => {
setCurrentUser(model as User | null);
if (model) {
console.info('Logged in as', model.email);
} else {
console.info('Logged out');
}
});
return unsubscribe;
client.realtime.subscribe(Collections.Tunes, (event) => {
console.info('Tunes event', event);
});
client.realtime.subscribe(Collections.Profiles, (event) => {
console.info('Profiles event', event);
});
return () => {
storeUnsubscribe();
client.realtime.unsubscribe(Collections.Tunes);
client.realtime.unsubscribe(Collections.Profiles);
};
}, []);
return (
<AuthContext.Provider value={value}>
{isLoading ? <Loader /> : children}
{children}
</AuthContext.Provider>
);
};

View File

@ -1,7 +1,7 @@
// darker
@text: #CECECE;
@main: #191C1E;
@main-dark: #1E1E1E;
@main-dark: #2a2a2a;
@main-light: #252525;
@main-darkest: #171717;

View File

@ -1,60 +1,39 @@
import * as Sentry from '@sentry/browser';
import {
ID,
Models,
Permission,
Query,
Role,
} from 'appwrite';
client,
formatError,
} from '../pocketbase';
import {
databases,
functions,
} from '../appwrite';
import {
TuneDbData,
UsersBucket,
TuneDbDataPartial,
TuneDbDocument,
TunesRecordFull,
TunesRecordPartial,
} from '../types/dbData';
import { databaseGenericError } from '../pages/auth/notifications';
const DB_ID = 'public';
const COLLECTION_ID_PUBLIC_TUNES = 'tunes';
const COLLECTION_ID_USERS_BUCKETS = 'usersBuckets';
import {
Collections,
TunesRecord,
} from '../@types/pocketbase-types';
const useDb = () => {
const updateTune = async (documentId: string, data: TuneDbDataPartial) => {
const updateTune = async (id: string, data: TunesRecordPartial) => {
try {
await databases.updateDocument(DB_ID, COLLECTION_ID_PUBLIC_TUNES, documentId, data);
await client.records.update(Collections.Tunes, id, data);
return Promise.resolve();
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
databaseGenericError(new Error(formatError(error)));
return Promise.reject(error);
}
};
const createTune = async (data: TuneDbData) => {
const createTune = async (data: TunesRecord) => {
try {
const tune = await databases.createDocument(
DB_ID,
COLLECTION_ID_PUBLIC_TUNES,
ID.unique(),
data,
[
Permission.read(Role.any()),
Permission.write(Role.user(data.userId, 'verified')),
],
);
const record = await client.records.create(Collections.Tunes, data);
return Promise.resolve(tune);
return Promise.resolve(record as TunesRecordFull);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
databaseGenericError(new Error(formatError(error)));
return Promise.reject(error);
}
@ -62,46 +41,15 @@ const useDb = () => {
const getTune = async (tuneId: string) => {
try {
const tune = await databases.listDocuments(
DB_ID,
COLLECTION_ID_PUBLIC_TUNES,
[
Query.equal('tuneId', tuneId),
Query.limit(1),
],
);
const tune = await client.records.getList(Collections.Tunes, 1, 1, {
filter: `tuneId = "${tuneId}"`,
expand: 'userProfile',
});
return Promise.resolve(tune.total > 0 ? tune.documents[0] as unknown as TuneDbDocument : null);
return Promise.resolve(tune.totalItems > 0 ? tune.items[0] as TunesRecordFull : null);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
return Promise.reject(error);
}
};
const getBucketId = async (userId: string) => {
try {
const buckets = await databases.listDocuments(
DB_ID,
COLLECTION_ID_USERS_BUCKETS,
[
Query.equal('userId', userId),
Query.equal('visibility', 'public'),
Query.limit(1),
],
);
if (buckets.total === 0) {
throw new Error('No public bucket found');
}
return Promise.resolve((buckets.documents[0] as unknown as UsersBucket)!.bucketId);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
databaseGenericError(new Error(formatError(error)));
return Promise.reject(error);
}
@ -109,34 +57,34 @@ const useDb = () => {
const searchTunes = async (search?: string) => {
// TODO: add pagination
const limit = 100;
const batchSide = 100;
const phrases = search ? search.replace(/ +(?= )/g,'').split(' ') : [];
const filter = phrases
.filter((phrase) => phrase.length > 1)
.map((phrase) => `textSearch ~ "${phrase}"`)
.join(' || ');
try {
const list: Models.DocumentList<TuneDbDocument> = await (
search
? databases.listDocuments(DB_ID, COLLECTION_ID_PUBLIC_TUNES, [Query.search('textSearch', search), Query.limit(limit)])
: databases.listDocuments(DB_ID, COLLECTION_ID_PUBLIC_TUNES, [Query.limit(limit)])
);
const list = await client.records.getFullList(Collections.Tunes, batchSide, {
sort: '-created',
filter,
expand: 'userProfile',
});
return Promise.resolve(list);
return Promise.resolve(list as TunesRecordFull[]);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
databaseGenericError(new Error(formatError(error)));
return Promise.reject(error);
}
};
return {
updateTune: (tuneId: string, data: TuneDbDataPartial): Promise<void> => updateTune(tuneId, data),
createTune: (data: TuneDbData): Promise<Models.Document> => createTune(data),
getTune: (tuneId: string): Promise<TuneDbDocument | null> => getTune(tuneId),
searchTunes: (search?: string): Promise<Models.DocumentList<TuneDbDocument>> => searchTunes(search),
getBucketId: (userId: string): Promise<string> => getBucketId(userId),
// TODO: refactor those executions
getUser: (userId: string) => functions.createExecution('getUser', JSON.stringify({ userId })),
listUsers: (userIds: string[]) => functions.createExecution('listUsers', JSON.stringify({ userIds })),
updateTune: (tuneId: string, data: TunesRecordPartial): Promise<void> => updateTune(tuneId, data),
createTune: (data: TunesRecord): Promise<TunesRecordFull> => createTune(data),
getTune: (tuneId: string): Promise<TunesRecordFull | null> => getTune(tuneId),
searchTunes: (search?: string): Promise<TunesRecordFull[]> => searchTunes(search),
};
};

View File

@ -1,33 +1,35 @@
import { notification } from 'antd';
import Pako from 'pako';
import * as Sentry from '@sentry/browser';
import {
ID,
Models,
Permission,
Role,
} from 'appwrite';
import { storage } from '../appwrite';
import { fetchEnv } from '../utils/env';
import { API_URL } from '../pocketbase';
import { Collections } from '../@types/pocketbase-types';
const PUBLIC_PATH = 'public';
const INI_PATH = `${PUBLIC_PATH}/ini`;
export const CDN_URL = fetchEnv('VITE_CDN_URL');
export type ServerFile = Models.File;
const genericError = (error: Error) => notification.error({ message: 'Storage Error', description: error.message });
const fetchFromServer = async (path: string): Promise<ArrayBuffer> => {
const response = await fetch(`${CDN_URL}/${path}`);
return Promise.resolve(response.arrayBuffer());
};
const fetchFileFromServer = async (recordId: string, filename: string, inflate = true): Promise<ArrayBuffer> => {
const response = await fetch(`${API_URL}/api/files/${Collections.Tunes}/${recordId}/${filename}`);
if (inflate) {
return Pako.inflate(new Uint8Array(await response.arrayBuffer()));
}
return response.arrayBuffer();
};
const useServerStorage = () => {
const getINIFile = async (signature: string) => {
const { version, baseVersion } = /.+?(?<version>(?<baseVersion>\d+)(-\w+)*)/.exec(signature)?.groups || { version: null, baseVersion: null };
try {
return fetchFromServer(`${INI_PATH}/${version}.ini.gz`);
return Pako.inflate(new Uint8Array(await fetchFromServer(`${INI_PATH}/${version}.ini.gz`)));
} catch (error) {
Sentry.captureException(error);
console.error(error);
@ -53,74 +55,9 @@ const useServerStorage = () => {
}
};
const removeFile = async (bucketId: string, fileId: string) => {
try {
await storage.deleteFile(bucketId, fileId);
return Promise.resolve();
} catch (error) {
Sentry.captureException(error);
console.error(error);
return Promise.reject(error);
}
};
const uploadFile = async (userId: string, bucketId: string, file: File) => {
try {
const createdFile = await storage.createFile(
bucketId,
ID.unique(),
file,
[
Permission.read(Role.any()),
Permission.write(Role.user(userId, 'verified')),
],
);
return Promise.resolve(createdFile);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const getFile = async (id: string, bucketId: string) => {
try {
const file = await storage.getFile(bucketId, id);
return Promise.resolve(file);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const getFileForDownload = async (id: string, bucketId: string) => {
try {
const file = storage.getFileView(bucketId, id);
const response = await fetch(file.href);
return Promise.resolve(response.arrayBuffer());
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
return {
getFile: (id: string, bucketId: string): Promise<Models.File> => getFile(id, bucketId),
getINIFile: (signature: string): Promise<ArrayBuffer> => getINIFile(signature),
removeFile: (bucketId: string, fileId: string): Promise<void> => removeFile(bucketId, fileId),
getFileForDownload: (bucketId: string, fileId: string): Promise<ArrayBuffer> => getFileForDownload(bucketId, fileId),
uploadFile: (userId: string, bucketId: string, file: File): Promise<ServerFile> => uploadFile(userId, bucketId, file),
fetchFileFromServer: (recordId: string, filename: string): Promise<ArrayBuffer> => fetchFileFromServer(recordId, filename),
};
};

View File

@ -12,7 +12,6 @@ import {
CopyOutlined,
StarOutlined,
ArrowRightOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import {
useCallback,
@ -29,75 +28,77 @@ import useDb from '../hooks/useDb';
import { Routes } from '../routes';
import { buildFullUrl } from '../utils/url';
import { aspirationMapper } from '../utils/tune/mappers';
import { TuneDbDocument } from '../types/dbData';
import {
copyToClipboard,
isClipboardSupported,
} from '../utils/clipboard';
import { ProfilesRecord } from '../@types/pocketbase-types';
import { isEscape } from '../utils/keyboard/shortcuts';
import { TunesRecordFull } from '../types/dbData';
const { useBreakpoint } = Grid;
const { Text, Title } = Typography;
interface ListUsersResponse {
users: [{
id: string;
name: string;
}];
}
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
const Hub = () => {
const { xs } = useBreakpoint();
const { searchTunes, listUsers } = useDb();
const { searchTunes } = useDb();
const navigate = useNavigate();
const [dataSource, setDataSource] = useState<{}[]>([]); // TODO: fix this type
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const searchRef = useRef<InputRef | null>(null);
const loadData = debounce(async (searchText?: string) => {
setIsLoading(true);
const list = await searchTunes(searchText);
// TODO: create `unpublishedTunes` collection for this
const filtered = list.documents.filter((tune) => !!tune.vehicleName);
// set initial list
setDataSource(filtered.map((tune) => ({
setDataSource(list.map((tune) => ({
...tune,
key: tune.tuneId,
year: tune.year,
author: <LoadingOutlined spin />,
author: (tune['@expand'] as { userProfile: ProfilesRecord }).userProfile.username,
displacement: `${tune.displacement}l`,
aspiration: aspirationMapper[tune.aspiration],
publishedAt: new Date(tune.$updatedAt).toLocaleString(),
created: new Date(tune.created).toLocaleString(),
stars: 0,
})));
setIsLoading(false);
// update list with users
const userList: ListUsersResponse = JSON.parse((await listUsers(filtered.map((tune) => tune.userId))).response);
setDataSource((prev) => prev.map((item: any) => ({
...item,
author: userList.users.find((el) => el.id === item.userId)?.name,
})));
}, 300);
const debounceLoadData = useCallback((value: string) => loadData(value), [loadData]);
const debounceLoadData = useCallback((value: string) => {
setSearchQuery(value);
loadData(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleGlobalKeyboard = useCallback((e: KeyboardEvent) => {
if (isEscape(e)) {
setSearchQuery('');
loadData();
}
}, [loadData]);
useEffect(() => {
loadData();
window.addEventListener('keydown', handleGlobalKeyboard);
// searchRef.current?.focus(); // autofocus
return () => window.removeEventListener('keydown', handleGlobalKeyboard);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<any> = [
{
title: 'Tunes',
render: (tune: TuneDbDocument) => (
render: (tune: TunesRecordFull) => (
<>
<Title level={5}>{tune.vehicleName}</Title>
<Space direction="vertical">
<Text type="secondary">{tune.author}, {tune.publishedAt}</Text>
<Text type="secondary">{tune.author}, {tune.created}</Text>
<Text>{tune.engineMake}, {tune.engineCode}, {tune.displacement}, {tune.cylindersCount} cylinders, {tune.aspiration}</Text>
<Text code>{tune.signature}</Text>
</Space>
@ -155,8 +156,8 @@ const Hub = () => {
},
{
title: 'Published',
dataIndex: 'publishedAt',
key: 'publishedAt',
dataIndex: 'created',
key: 'created',
responsive: ['sm'],
},
{
@ -186,6 +187,7 @@ const Hub = () => {
tabIndex={1}
ref={searchRef}
style={{ marginBottom: 10, height: 40 }}
value={searchQuery}
placeholder="Search..."
onChange={({ target }) => debounceLoadData(target.value)}
allowClear

View File

@ -1,7 +1,3 @@
import {
useEffect,
useState,
} from 'react';
import { connect } from 'react-redux';
import ReactMarkdown from 'react-markdown';
import {
@ -25,7 +21,6 @@ import {
import Loader from '../components/Loader';
import { Routes } from '../routes';
import { useAuth } from '../contexts/AuthContext';
import useDb from '../hooks/useDb';
const { Item } = Form;
const rowProps = { gutter: 10 };
@ -35,35 +30,15 @@ const mapStateToProps = (state: AppState) => ({
tuneData: state.tuneData,
});
interface GetUserResponse {
id: string;
name: string;
tunes: [];
}
const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
const navigate = useNavigate();
const { currentUser } = useAuth();
const { getUser } = useDb();
const [author, setAuthor] = useState<string>();
const goToEdit = () => navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId: tuneData.tuneId,
}));
const loadData = async () => {
const authorData: GetUserResponse = JSON.parse((await getUser(tuneData.userId)).response);
setAuthor(authorData.name);
};
useEffect(() => {
if (tuneData?.userId) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tuneData]);
const canManage = tuneData?.userId === currentUser?.$id;
const canManage = currentUser && tuneData && currentUser.id === tuneData.user;
const manageSection = (
<>
@ -94,7 +69,7 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
<Row {...rowProps}>
<Col span={24} sm={24}>
<Item>
<Input value={author || 'loading...'} addonBefore="Author" />
<Input value={tuneData['@expand'].userProfile.username} addonBefore="Author" />
</Item>
</Col>
</Row>

View File

@ -30,8 +30,9 @@ import {
EditOutlined,
CheckOutlined,
SendOutlined,
GlobalOutlined,
EyeOutlined,
} from '@ant-design/icons';
import * as Sentry from '@sentry/browser';
import { INI } from '@hyper-tuner/ini';
import { UploadRequestOption } from 'rc-upload/lib/interface';
import { UploadFile } from 'antd/lib/upload/interface';
@ -40,11 +41,13 @@ import {
useMatch,
useNavigate,
} from 'react-router-dom';
import Pako from 'pako';
import ReactMarkdown from 'react-markdown';
import { nanoid } from 'nanoid';
import {
emailNotVerified,
restrictedPage,
usernameNotSet,
} from './auth/notifications';
import { useAuth } from '../contexts/AuthContext';
import { Routes } from '../routes';
@ -52,16 +55,20 @@ import TuneParser from '../utils/tune/TuneParser';
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
import LogParser from '../utils/logs/LogParser';
import useDb from '../hooks/useDb';
import useServerStorage, { ServerFile } from '../hooks/useServerStorage';
import useServerStorage from '../hooks/useServerStorage';
import { buildFullUrl } from '../utils/url';
import Loader from '../components/Loader';
import {
requiredTextRules,
requiredRules,
} from '../utils/form';
import { TuneDbDocument } from '../types/dbData';
import { aspirationMapper } from '../utils/tune/mappers';
import { copyToClipboard } from '../utils/clipboard';
import { TunesRecord } from '../@types/pocketbase-types';
import {
TunesRecordFull,
TunesRecordPartial,
} from '../types/dbData';
const { Item, useForm } = Form;
@ -78,7 +85,6 @@ interface ValidationResult {
}
type ValidateFile = (file: File) => Promise<ValidationResult>;
type UploadDone = (fileCreated: ServerFile, file: File) => void;
const rowProps = { gutter: 10 };
const colProps = { span: 24, sm: 12 };
@ -97,6 +103,8 @@ const iniIcon = () => <FileTextOutlined />;
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
const tuneParser = new TuneParser();
const bufferToFile = (buffer: ArrayBuffer, name: string) => new File([buffer], name);
const UploadPage = () => {
const [form] = useForm();
const routeMatch = useMatch(Routes.UPLOAD_WITH_TUNE_ID);
@ -104,39 +112,42 @@ const UploadPage = () => {
const [isLoading, setIsLoading] = useState(false);
const [isTuneLoading, setTuneIsLoading] = useState(true);
const [newTuneId, setNewTuneId] = useState<string>();
const [tuneDocumentId, setTuneDocumentId] = useState<string>();
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
const [shareUrl, setShareUrl] = useState<string>();
const [isPublished, setIsPublished] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [readme, setReadme] = useState(defaultReadme);
const [existingTune, setExistingTune] = useState<TuneDbDocument>();
const [existingTune, setExistingTune] = useState<TunesRecordFull>();
const [defaultTuneFileList, setDefaultTuneFileList] = useState<UploadFile[]>([]);
const [defaultLogFilesList, setDefaultLogFilesList] = useState<UploadFile[]>([]);
const [defaultToothLogFilesList, setDefaultToothLogFilesList] = useState<UploadFile[]>([]);
const [defaultCustomIniFileList, setDefaultCustomIniFileList] = useState<UploadFile[]>([]);
const [tuneFileId, setTuneFileId] = useState<string | null>(null);
const [logFileIds, setLogFileIds] = useState<Map<string, string>>(new Map());
const [toothLogFileIds, setToothLogFileIds] = useState<Map<string, string>>(new Map());
const [customIniFileId, setCustomIniFileId] = useState<string | null>(null);
const [tuneFile, setTuneFile] = useState<File>();
const [customIniFile, setCustomIniFile] = useState<File>();
const [logFiles, setLogFiles] = useState<File[]>([]);
const [toothLogFiles, setToothLogFiles] = useState<File[]>([]);
const shareSupported = 'share' in navigator;
const { currentUser } = useAuth();
const { currentUser, refreshUser } = useAuth();
const navigate = useNavigate();
const { removeFile, uploadFile, getFile } = useServerStorage();
const { createTune, getBucketId, updateTune, getTune } = useDb();
const { fetchFileFromServer } = useServerStorage();
const { createTune, updateTune, getTune } = useDb();
const fetchFile = async (tuneId: string, fileName: string) => bufferToFile(await fetchFileFromServer(tuneId, fileName), fileName);
const noop = () => { };
const removeFilenameSuffix = (filename: string) => filename.replace(/(.+)(_\w{10})(\.\w+)$/, '$1$3');
const goToNewTune = () => navigate(generatePath(Routes.TUNE_TUNE, {
tuneId: newTuneId!,
}));
const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message });
const publishTune = async (values: any) => {
setIsLoading(true);
/* eslint-disable prefer-destructuring */
const vehicleName = values.vehicleName.trim();
const engineMake = values.engineMake.trim();
@ -151,10 +162,36 @@ const UploadPage = () => {
const year = values.year || null;
const hp = values.hp || null;
const stockHp = values.stockHp || null;
const visibility = values.visibility;
/* eslint-enable prefer-destructuring */
setIsLoading(true);
await updateTune(tuneDocumentId!, {
const compressedTuneFile = bufferToFile(
Pako.deflate(await tuneFile!.arrayBuffer()),
removeFilenameSuffix(tuneFile!.name),
);
const compressedCustomIniFile = customIniFile ? bufferToFile(
Pako.deflate(await customIniFile!.arrayBuffer()),
removeFilenameSuffix(customIniFile!.name),
) : null;
const compressedLogFiles = await Promise.all(logFiles.map(async (file) => bufferToFile(
Pako.deflate(await file.arrayBuffer()),
removeFilenameSuffix(file.name),
)));
const compressedToothLogFiles = await Promise.all(toothLogFiles.map(async (file) => bufferToFile(
Pako.deflate(await file.arrayBuffer()),
removeFilenameSuffix(file.name),
)));
const { signature } = tuneParser.parse(await tuneFile!.arrayBuffer()).getTune().details;
const newData: TunesRecord = {
user: currentUser!.id,
userProfile: currentUser!.profile!.id,
tuneId: newTuneId!,
signature,
vehicleName,
engineMake,
engineCode,
@ -169,8 +206,13 @@ const UploadPage = () => {
hp,
stockHp,
readme: readme?.trim(),
visibility,
tuneFile: compressedTuneFile as unknown as string,
customIniFile: compressedCustomIniFile as unknown as string,
logFiles: compressedLogFiles as unknown as string[],
toothLogFiles: compressedToothLogFiles as unknown as string[],
textSearch: [
existingTune?.signature,
signature,
vehicleName,
engineMake,
engineCode,
@ -182,7 +224,36 @@ const UploadPage = () => {
].filter((field) => field !== null && `${field}`.length > 1)
.join(' ')
.replace(/[^A-z.\-\d ]/g, ''),
};
const formData = new FormData();
Object.keys(newData).forEach((key) => {
const value = (newData as any)[key];
if (Array.isArray(value)) {
value.forEach((file: File) => {
formData.append(key, file);
});
} else {
formData.append(key, value);
}
});
if (existingTune) {
// clear old multi files first
if (logFiles.length > 0 || toothLogFiles.length > 0) {
const tempFormData = new FormData();
tempFormData.append('logFiles', '');
tempFormData.append('toothLogFiles', '');
await updateTune(existingTune.id, tempFormData as unknown as TunesRecord);
}
await updateTune(existingTune.id, formData as unknown as TunesRecord);
} else {
await createTune(formData as unknown as TunesRecord);
}
setIsLoading(false);
setIsPublished(true);
};
@ -198,7 +269,7 @@ const UploadPage = () => {
}), { replace: true });
}, [navigate]);
const upload = async (options: UploadRequestOption, done: UploadDone, validate: ValidateFile) => {
const upload = async (options: UploadRequestOption, done: (file: File) => void, validate: ValidateFile) => {
const { onError, onSuccess, file } = options;
const validation = await validate(file as File);
@ -211,54 +282,15 @@ const UploadPage = () => {
return false;
}
try {
const pako = await import('pako');
const buffer = await (file as File).arrayBuffer();
const compressed = pako.deflate(new Uint8Array(buffer));
const bucketId = await getBucketId(currentUser!.$id);
const fileCreated: ServerFile = await uploadFile(currentUser!.$id, bucketId, new File([compressed], (file as File).name));
done(fileCreated, file as File);
onSuccess!(null);
} catch (error) {
Sentry.captureException(error);
console.error('Upload error:', error);
notification.error({ message: 'Upload error', description: (error as Error).message });
onError!(error as Error);
return false;
}
done(file as File);
onSuccess!(null);
return true;
};
const uploadTune = async (options: UploadRequestOption) => {
upload(options, async (fileCreated: ServerFile, file: File) => {
const { signature } = tuneParser.parse(await file.arrayBuffer()).getTune().details;
if (tuneDocumentId) {
await updateTune(tuneDocumentId, {
signature,
tuneFileId: fileCreated.$id,
});
} else {
const document = await createTune({
userId: currentUser!.$id,
tuneId: newTuneId!,
signature,
tuneFileId: fileCreated.$id,
vehicleName: '',
displacement: 0,
cylindersCount: 0,
engineMake: '',
engineCode: '',
aspiration: 'na',
readme: '',
});
setTuneDocumentId(document.$id);
}
setTuneFileId(fileCreated.$id);
upload(options, async (file) => {
setTuneFile(file);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -273,10 +305,8 @@ const UploadPage = () => {
};
const uploadLogs = async (options: UploadRequestOption) => {
upload(options, async (fileCreated) => {
const newValues = new Map(logFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
await updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
setLogFileIds(newValues);
upload(options, async (file) => {
setLogFiles((prev) => [...prev, file]);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -308,10 +338,8 @@ const UploadPage = () => {
};
const uploadToothLogs = async (options: UploadRequestOption) => {
upload(options, async (fileCreated) => {
const newValues = new Map(toothLogFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
await updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
setToothLogFileIds(newValues);
upload(options, async (file) => {
setToothLogFiles((prev) => [...prev, file]);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -328,9 +356,8 @@ const UploadPage = () => {
};
const uploadCustomIni = async (options: UploadRequestOption) => {
upload(options, async (fileCreated) => {
await updateTune(tuneDocumentId!, { customIniFileId: fileCreated.$id });
setCustomIniFileId(fileCreated.$id);
upload(options, async (file) => {
setCustomIniFile(file);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -354,54 +381,20 @@ const UploadPage = () => {
});
};
const removeFileFromStorage = async (fileId: string | null): Promise<boolean> => {
if (!fileId) {
return false;
}
await removeFile(await getBucketId(currentUser!.$id), fileId);
return true;
};
const removeTuneFile = async () => {
if (!await removeFileFromStorage(tuneFileId!)) {
return;
}
await updateTune(tuneDocumentId!, { tuneFileId: null });
setTuneFileId(null);
setTuneFile(undefined);
};
const removeLogFile = async (file: UploadFile) => {
if (!await removeFileFromStorage(logFileIds.get(file.uid)!)) {
return;
}
logFileIds.delete(file.uid);
const newValues = new Map(logFileIds);
setLogFileIds(newValues);
updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
setLogFiles((prev) => prev.filter((f) => f.name !== file.name));
};
const removeToothLogFile = async (file: UploadFile) => {
if (!await removeFileFromStorage(toothLogFileIds.get(file.uid)!)) {
return;
}
toothLogFileIds.delete(file.uid);
const newValues = new Map(toothLogFileIds);
setToothLogFileIds(newValues);
updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
setToothLogFiles((prev) => prev.filter((f) => f.name !== file.name));
};
const removeCustomIniFile = async (file: UploadFile) => {
if (!await removeFileFromStorage(customIniFileId!)) {
return;
}
await updateTune(tuneDocumentId!, { customIniFileId: null });
setCustomIniFileId(null);
setCustomIniFile(undefined);
};
const loadExistingTune = useCallback(async (currentTuneId: string) => {
@ -411,7 +404,7 @@ const UploadPage = () => {
const oldTune = await getTune(currentTuneId);
if (oldTune) {
// this is someone elses tune
if (oldTune.userId !== currentUser?.$id) {
if (oldTune.user !== currentUser?.id) {
navigateToNewTuneId();
return;
}
@ -419,54 +412,54 @@ const UploadPage = () => {
setExistingTune(oldTune);
form.setFieldsValue(oldTune);
setIsEditMode(true);
setTuneDocumentId(oldTune.$id);
setReadme(oldTune.readme!);
if (oldTune.tuneFileId) {
const file = await getFile(oldTune.tuneFileId, await getBucketId(currentUser!.$id));
setTuneFileId(oldTune.tuneFileId);
if (oldTune.tuneFile) {
setTuneFile(await fetchFile(oldTune.id, oldTune.tuneFile));
setDefaultTuneFileList([{
uid: file.$id,
name: file.name,
uid: oldTune.tuneFile,
name: oldTune.tuneFile,
status: 'done',
}]);
}
if (oldTune.customIniFileId) {
const file = await getFile(oldTune.customIniFileId, await getBucketId(currentUser!.$id));
setCustomIniFileId(oldTune.customIniFileId);
if (oldTune.customIniFile) {
setCustomIniFile(await fetchFile(oldTune.id, oldTune.customIniFile));
setDefaultCustomIniFileList([{
uid: file.$id,
name: file.name,
uid: oldTune.customIniFile,
name: oldTune.customIniFile,
status: 'done',
}]);
}
oldTune.logFileIds?.forEach(async (fileId: string) => {
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
setLogFileIds((prev) => new Map(prev).set(fileId, fileId));
const tempLogFiles: File[] = [];
oldTune.logFiles?.forEach(async (fileName: string) => {
tempLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultLogFilesList((prev) => [...prev, {
uid: file.$id,
name: file.name,
uid: fileName,
name: fileName,
status: 'done',
}]);
});
setLogFiles(tempLogFiles);
oldTune.toothLogFileIds?.forEach(async (fileId: string) => {
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
setToothLogFileIds((prev) => new Map(prev).set(fileId, fileId));
const tempToothLogFiles: File[] = [];
oldTune.toothLogFiles?.forEach(async (fileName: string) => {
tempToothLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultToothLogFilesList((prev) => [...prev, {
uid: file.$id,
name: file.name,
uid: fileName,
name: fileName,
status: 'done',
}]);
});
setToothLogFiles(tempToothLogFiles);
} else {
// reset state
form.resetFields();
setReadme(defaultReadme);
setTuneFileId(null);
setCustomIniFileId(null);
setTuneFile(undefined);
setCustomIniFile(undefined);
setDefaultTuneFileList([]);
setDefaultLogFilesList([]);
setDefaultToothLogFilesList([]);
@ -488,29 +481,40 @@ const UploadPage = () => {
}, [loadExistingTune, navigateToNewTuneId, routeMatch?.params.tuneId]);
useEffect(() => {
if (!currentUser) {
restrictedPage();
navigate(Routes.LOGIN);
return;
}
try {
if (!currentUser.emailVerification) {
emailNotVerified();
refreshUser().then((user) => {
if (user === null) {
restrictedPage();
navigate(Routes.LOGIN);
return;
}
setIsUserAuthorized(true);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
}
prepareData();
}, [currentUser, navigate, prepareData, routeMatch?.params.tuneId]);
if (!user) {
restrictedPage();
navigate(Routes.LOGIN);
return;
}
if (!user.verified) {
emailNotVerified();
navigate(Routes.PROFILE);
return;
}
if ((user.profile?.username?.length || 0) === 0) {
usernameNotSet();
navigate(Routes.PROFILE);
return;
}
setIsUserAuthorized(true);
prepareData();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeMatch?.params.tuneId]);
const uploadButton = (
<Space direction="vertical">
@ -518,10 +522,39 @@ const UploadPage = () => {
</Space>
);
const shareSection = (
const publishButton = (
<Row style={{ marginTop: 10 }} {...rowProps}>
<Col {...colProps}>
<Item name="visibility" rules={requiredTextRules}>
<Select disabled>
<Select.Option value="public">
<Space><GlobalOutlined />Public</Space>
</Select.Option>
<Select.Option value="unlisted">
<Space><EyeOutlined />Unlisted</Space>
</Select.Option>
</Select>
</Item>
</Col>
<Col {...colProps}>
<Item style={{ width: '100%' }}>
<Button
type="primary"
block
loading={isLoading}
htmlType="submit"
icon={isEditMode ? <EditOutlined /> : <CheckOutlined />}
>
{isEditMode ? 'Update' : 'Publish'}
</Button>
</Item>
</Col>
</Row>
);
const openButton = (
<>
<Divider>Publish & Share</Divider>
{isPublished && <Row>
<Row>
<Input
style={{ width: `calc(100% - ${shareSupported ? 65 : 35}px)` }}
value={shareUrl!}
@ -537,30 +570,29 @@ const UploadPage = () => {
/>
</Tooltip>
)}
</Row>}
</Row>
<Row style={{ marginTop: 10 }}>
<Item style={{ width: '100%' }}>
{!isPublished ? <Button
type="primary"
block
loading={isLoading}
htmlType="submit"
icon={isEditMode ? <EditOutlined /> : <CheckOutlined />}
>
{isEditMode ? 'Update' : 'Publish'}
</Button> : <Button
<Button
type="primary"
block
onClick={goToNewTune}
icon={<SendOutlined />}
>
Open
</Button>}
</Button>
</Item>
</Row>
</>
);
const shareSection = (
<>
<Divider>Publish & Share</Divider>
{isPublished ? openButton : publishButton}
</>
);
const detailsSection = (
<>
<Divider>
@ -711,7 +743,7 @@ const UploadPage = () => {
defaultFileList={defaultLogFilesList}
accept=".mlg,.csv,.msl"
>
{logFileIds.size < MaxFiles.LOG_FILES && uploadButton}
{logFiles.length < MaxFiles.LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
@ -731,7 +763,7 @@ const UploadPage = () => {
defaultFileList={defaultToothLogFilesList}
accept=".csv"
>
{toothLogFileIds.size < MaxFiles.TOOTH_LOG_FILES && uploadButton}
{toothLogFiles.length < MaxFiles.TOOTH_LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
@ -750,17 +782,13 @@ const UploadPage = () => {
defaultFileList={defaultCustomIniFileList}
accept=".ini"
>
{!customIniFileId && uploadButton}
{!customIniFile && uploadButton}
</Upload>
{detailsSection}
{shareUrl && tuneFileId && shareSection}
{shareUrl && tuneFile && shareSection}
</>
);
if (!isUserAuthorized || isTuneLoading) {
return <Loader />;
}
if (isPublished) {
return (
<div className="small-container">
@ -769,9 +797,28 @@ const UploadPage = () => {
);
}
if (!isUserAuthorized || isTuneLoading) {
return (
<Form form={form}>
<Loader />
</Form>
);
}
return (
<div className="small-container">
<Form onFinish={publishTune} initialValues={{ readme }} form={form}>
<Form
initialValues={{
aspiration: 'na',
readme,
visibility: 'public',
cylindersCount: 4,
displacement: 1.6,
year: thisYear,
} as TunesRecordPartial}
form={form}
onFinish={publishTune}
>
<Divider>
<Space>
Upload Tune
@ -789,9 +836,9 @@ const UploadPage = () => {
defaultFileList={defaultTuneFileList}
accept=".msq"
>
{tuneFileId === null && uploadButton}
{tuneFile === undefined && uploadButton}
</Upload>
{(tuneFileId || defaultTuneFileList.length > 0) && optionalSection}
{(tuneFile || defaultTuneFileList.length > 0) && optionalSection}
</Form>
</div>
);

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react';
import {
useMatch,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import Loader from '../../components/Loader';
import { useAuth } from '../../contexts/AuthContext';
@ -14,24 +14,17 @@ import {
const EmailVerification = () => {
const { confirmEmailVerification } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
const routeMatch = useMatch(Routes.EMAIL_VERIFICATION);
useEffect(() => {
if (userId && secret) {
confirmEmailVerification(userId, secret)
.then(() => emailVerificationSuccess())
.catch((error) => {
console.error(error);
emailVerificationFailed(error);
});
} else {
emailVerificationFailed(new Error('Invalid URL'));
}
confirmEmailVerification(routeMatch!.params.token!)
.then(() => emailVerificationSuccess())
.catch((error) => {
emailVerificationFailed(error);
});
navigate(Routes.HUB);
});
}, [confirmEmailVerification, navigate, routeMatch]);
return <Loader />;
};

View File

@ -1,5 +1,7 @@
import {
ReactNode,
useCallback,
useEffect,
useState,
} from 'react';
import {
@ -16,162 +18,222 @@ import {
GoogleOutlined,
GithubOutlined,
FacebookOutlined,
UserAddOutlined,
} from '@ant-design/icons';
import {
Link,
useNavigate,
} from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import {
OAuthProviders,
useAuth,
} from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import validateMessages from './validateMessages';
import {
emailNotVerified,
logInFailed,
logInSuccessful,
magicLinkSent,
signUpFailed,
signUpSuccessful,
} from './notifications';
import {
emailRules,
requiredRules,
} from '../../utils/form';
import { buildRedirectUrl } from '../../utils/url';
const { Item } = Form;
const Login = () => {
const [formMagicLink] = Form.useForm();
export enum FormRoles {
LOGIN = 'Login',
SING_UP = 'Sign Up',
}
const Login = ({ formRole }: { formRole: FormRoles }) => {
const [formEmail] = Form.useForm();
const isLogin = formRole === FormRoles.LOGIN;
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
const { login, googleAuth, githubAuth, facebookAuth, sendMagicLink } = useAuth();
const [isOAuthLoading, setIsOAuthLoading] = useState(false);
const [providersReady, setProvidersReady] = useState(false);
const [googleUrl, setGoogleUrl] = useState<string | null>(null);
const [githubUrl, setGithubUrl] = useState<string | null>(null);
const [facebookUrl, setFacebookUrl] = useState<string | null>(null);
const [providersStatuses, setProvidersStatuses] = useState<{ [key: string]: boolean }>({
google: false,
github: false,
facebook: false,
});
const { login, signUp, listAuthMethods } = useAuth();
const navigate = useNavigate();
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
const isAnythingLoading = isEmailLoading || isOAuthLoading;
const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]);
const googleLogin = useCallback(async () => {
setIsGoogleLoading(true);
try {
await googleAuth();
} catch (error) {
logInFailed(error as Error);
}
}, [googleAuth]);
const githubLogin = useCallback(async () => {
setIsGithubLoading(true);
try {
await githubAuth();
} catch (error) {
logInFailed(error as Error);
}
}, [githubAuth]);
const facebookLogin = async () => {
setIsFacebookLoading(true);
try {
await facebookAuth();
} catch (error) {
logInFailed(error as Error);
}
};
const isOauthEnabled = Object.values(providersStatuses).includes(true);
const emailLogin = async ({ email, password }: { email: string, password: string }) => {
setIsEmailLoading(true);
try {
const user = await login(email, password);
logInSuccessful();
if (!user.emailVerification) {
if (!user.verified) {
emailNotVerified();
}
if (!user.name) {
navigate(Routes.PROFILE);
}
redirectAfterLogin();
} catch (error) {
console.warn(error);
logInFailed(error as Error);
formMagicLink.resetFields();
formEmail.resetFields();
setIsEmailLoading(false);
}
};
const magicLinkLogin = async ({ email }: { email: string }) => {
setIsMagicLinkLoading(true);
const emailSignUp = async ({ email, password }: { email: string, password: string }) => {
setIsEmailLoading(true);
try {
await sendMagicLink(email);
magicLinkSent();
const user = await signUp(email, password);
signUpSuccessful();
if (!user.verified) {
emailNotVerified();
}
navigate(Routes.HUB);
} catch (error) {
logInFailed(error as Error);
} finally {
setIsMagicLinkLoading(false);
formMagicLink.resetFields();
signUpFailed(error as Error);
formEmail.resetFields();
setIsEmailLoading(false);
}
};
return (
<div className="auth-container">
<Divider>Log In</Divider>
const oauthMethods: { [provider: string]: { label: string, icon: ReactNode, onClick: () => void } } = {
google: {
label: 'Google',
icon: <GoogleOutlined />,
onClick: () => {
setIsOAuthLoading(true);
window.location.href = googleUrl!;
},
},
github: {
label: 'GitHub',
icon: <GithubOutlined />,
onClick: () => {
setIsOAuthLoading(true);
window.location.href = githubUrl!;
},
},
facebook: {
label: 'Facebook',
icon: <FacebookOutlined />,
onClick: () => {
setIsOAuthLoading(true);
window.location.href = facebookUrl!;
},
},
};
useEffect(() => {
listAuthMethods().then((methods) => {
const { authProviders } = methods;
window.localStorage.setItem('authProviders', JSON.stringify(authProviders));
authProviders.forEach((provider) => {
if (provider.name) {
setProvidersReady(true);
}
switch (provider.name) {
case OAuthProviders.GOOGLE:
setProvidersStatuses((prevState) => ({ ...prevState, [provider.name]: true }));
setGoogleUrl(`${provider.authUrl}${encodeURIComponent(buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider: provider.name }))}`);
break;
case OAuthProviders.GITHUB:
setProvidersStatuses((prevState) => ({ ...prevState, [provider.name]: true }));
setGithubUrl(`${provider.authUrl}${encodeURIComponent(buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider: provider.name }))}`);
break;
case OAuthProviders.FACEBOOK:
setProvidersStatuses((prevState) => ({ ...prevState, [provider.name]: true }));
setFacebookUrl(`${provider.authUrl}${encodeURIComponent(buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider: provider.name }))}`);
break;
default:
throw new Error(`Unsupported provider: ${provider.name}`);
}
});
});
}, [listAuthMethods]);
const oauthSection = (
isOauthEnabled && <>
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
<Button
loading={isGoogleLoading}
onClick={googleLogin}
disabled={isAnythingLoading}
>
<GoogleOutlined />Google
</Button>
<Button
loading={isGithubLoading}
onClick={githubLogin}
disabled={isAnythingLoading}
>
<GithubOutlined />GitHub
</Button>
<Button
loading={isFacebookLoading}
onClick={facebookLogin}
disabled={isAnythingLoading}
>
<FacebookOutlined />Facebook
</Button>
{providersReady && Object.keys(oauthMethods).map((provider) => (
providersStatuses[provider] && <Button
key={provider}
icon={oauthMethods[provider].icon}
onClick={oauthMethods[provider].onClick}
loading={isOAuthLoading}
>
{oauthMethods[provider].label}
</Button>
))}
</Space>
<Divider />
</>
);
const bottomLinksLogin = (
<>
<Link to={Routes.SIGN_UP}>
Sign Up
</Link>
<Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
Forgot password?
</Link>
</>
);
const bottomLinksSignUp = (
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
Log In
</Link>
);
const submitLogin = (
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isEmailLoading}
disabled={isAnythingLoading}
icon={<UnlockOutlined />}
>
Login using password
</Button>
);
const submitSignUp = (
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isEmailLoading}
disabled={isAnythingLoading}
icon={<UserAddOutlined />}
>
Sign Up using password
</Button>
);
return (
<div className="auth-container">
<Divider>{formRole}</Divider>
{providersReady && oauthSection}
<Form
onFinish={magicLinkLogin}
validateMessages={validateMessages}
form={formMagicLink}
>
<Item name="email" rules={emailRules} hasFeedback>
<Input
prefix={<MailOutlined />}
placeholder="Email"
id="email-magic-link"
autoComplete="email"
disabled={isAnythingLoading}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isMagicLinkLoading}
disabled={isAnythingLoading}
icon={<MailOutlined />}
>
Send me a Magic Link
</Button>
</Item>
</Form>
<Form
onFinish={emailLogin}
onFinish={isLogin ? emailLogin : emailSignUp}
validateMessages={validateMessages}
form={formEmail}
>
<Divider />
<Item name="email" rules={emailRules} hasFeedback>
<Input
prefix={<MailOutlined />}
@ -193,23 +255,9 @@ const Login = () => {
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isEmailLoading}
disabled={isAnythingLoading}
icon={<UnlockOutlined />}
>
Log in using password
</Button>
{isLogin ? submitLogin : submitSignUp}
</Item>
<Link to={Routes.SIGN_UP}>
Sign Up
</Link>
<Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
Forgot password?
</Link>
{isLogin ? bottomLinksLogin : bottomLinksSignUp}
</Form>
</div>
);

View File

@ -1,39 +0,0 @@
import { useEffect } from 'react';
import {
useNavigate,
useSearchParams,
} from 'react-router-dom';
import Loader from '../../components/Loader';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import {
logInSuccessful,
magicLinkConfirmationFailed,
} from './notifications';
const MagicLinkConfirmation = () => {
const { confirmMagicLink } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
useEffect(() => {
if (userId && secret) {
confirmMagicLink(userId, secret)
.then(() => logInSuccessful())
.catch((error) => {
console.error(error);
magicLinkConfirmationFailed(error);
});
} else {
magicLinkConfirmationFailed(new Error('Invalid URL'));
}
navigate(Routes.HUB);
});
return <Loader />;
};
export default MagicLinkConfirmation;

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import {
useMatch,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import Loader from '../../components/Loader';
import {
AuthProviderInfo,
OAuthProviders,
useAuth,
} from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import { logInSuccessful } from './notifications';
const OauthCallback = () => {
const { oAuth } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const routeMatch = useMatch(Routes.OAUTH_CALLBACK);
useEffect(() => {
const authProviders = JSON.parse(window.localStorage.getItem('authProviders') || '') as unknown as AuthProviderInfo[];
oAuth(
routeMatch?.params.provider as OAuthProviders,
searchParams.get('code')!,
authProviders.find((provider) => provider.name === routeMatch?.params.provider)?.codeVerifier!,
)
.then(() => {
logInSuccessful();
navigate(Routes.HUB, { replace: true });
});
}, [navigate, oAuth, routeMatch, searchParams]);
return <Loader />;
};
export default OauthCallback;

View File

@ -1,5 +1,4 @@
import {
useCallback,
useEffect,
useState,
} from 'react';
@ -11,12 +10,10 @@ import {
Divider,
Alert,
Space,
List,
} from 'antd';
import {
UserOutlined,
MailOutlined,
LockOutlined,
} from '@ant-design/icons';
import validateMessages from './validateMessages';
import { useAuth } from '../../contexts/AuthContext';
@ -26,42 +23,24 @@ import {
emailVerificationSent,
profileUpdateSuccess,
profileUpdateFailed,
passwordUpdateSuccess,
passwordUpdateFailed,
} from './notifications';
import { Routes } from '../../routes';
import {
passwordRules,
requiredRules,
} from '../../utils/form';
import { usernameRules } from '../../utils/form';
const { Item } = Form;
const MAX_LIST_SIZE = 10;
const parseLogEvent = (raw: string) => {
const split = raw.split('.');
return [split[0], split[2], split[4]].join(' ');
};
const Profile = () => {
const [formProfile] = Form.useForm();
const [formPassword] = Form.useForm();
const {
currentUser,
sendEmailVerification,
updateUsername,
updatePassword,
getSessions,
getLogs,
refreshUser,
} = useAuth();
const navigate = useNavigate();
const [isVerificationSent, setIsVerificationSent] = useState(false);
const [isSendingVerification, setIsSendingVerification] = useState(false);
const [isProfileLoading, setIsProfileLoading] = useState(false);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [sessions, setSessions] = useState<string[] | null>(null);
const [logs, setLogs] = useState<string[] | null>(null);
const resendEmailVerification = async () => {
setIsSendingVerification(true);
@ -77,24 +56,12 @@ const Profile = () => {
}
};
const fetchLogs = useCallback(async () => getLogs()
.then((list) => setLogs(list.logs.slice(0, MAX_LIST_SIZE).map((log) => [
new Date(log.time).toLocaleString(),
parseLogEvent(log.event),
log.clientName,
log.clientEngineVersion,
log.osName,
log.deviceName,
log.countryName,
log.ip,
].join(' | ')))), [getLogs]);
const onUpdateProfile = async ({ username }: { username: string }) => {
setIsProfileLoading(true);
try {
await updateUsername(username);
profileUpdateSuccess();
fetchLogs();
refreshUser();
} catch (error) {
profileUpdateFailed(error as Error);
} finally {
@ -102,59 +69,44 @@ const Profile = () => {
}
};
const onUpdatePassword = async ({ password, oldPassword }: { password: string, oldPassword: string }) => {
setIsPasswordLoading(true);
try {
await updatePassword(password, oldPassword);
passwordUpdateSuccess();
fetchLogs();
formPassword.resetFields();
} catch (error) {
passwordUpdateFailed(error as Error);
} finally {
setIsPasswordLoading(false);
}
};
useEffect(() => {
if (currentUser) {
getSessions()
.then((list) => setSessions(list.sessions.slice(0, MAX_LIST_SIZE).map((ses) => [
ses.clientName,
ses.osName,
ses.deviceName,
ses.countryName,
ses.ip,
].join(' | '))));
if (!currentUser) {
restrictedPage();
navigate(Routes.LOGIN);
fetchLogs();
return;
}
restrictedPage();
navigate(Routes.LOGIN);
}, [currentUser, fetchLogs, getLogs, getSessions, navigate]);
refreshUser().then((user) => {
if (currentUser === null || user === null) {
restrictedPage();
navigate(Routes.LOGIN);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="auth-container">
{!currentUser?.emailVerification && (<>
<Divider>Email verification</Divider>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert message="Your email address is not verified!" type="error" showIcon />
<Button
type="primary"
style={{ width: '100%' }}
icon={<MailOutlined />}
disabled={isVerificationSent}
loading={isSendingVerification}
onClick={resendEmailVerification}
>
Resend verification
</Button>
</Space>
</>)}
<Divider>Your Profile</Divider>
<div className="auth-container">
{!currentUser?.verified && (<>
<Divider>Email verification</Divider>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert message="Your email address is not verified!" type="error" showIcon />
<Button
type="primary"
style={{ width: '100%' }}
icon={<MailOutlined />}
disabled={isVerificationSent}
loading={isSendingVerification}
onClick={resendEmailVerification}
>
Resend verification
</Button>
</Space>
</>)}
<Divider>Your Profile</Divider>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{(currentUser?.profile?.username?.length || 0) === 0 && <Alert message="Remember to set your username!" type="error" showIcon />}
<Form
validateMessages={validateMessages}
form={formProfile}
@ -162,7 +114,7 @@ const Profile = () => {
fields={[
{
name: 'username',
value: currentUser?.name,
value: currentUser?.profile?.username,
},
{
name: 'email',
@ -172,7 +124,7 @@ const Profile = () => {
>
<Item
name="username"
rules={requiredRules}
rules={usernameRules}
hasFeedback
>
<Input
@ -196,66 +148,8 @@ const Profile = () => {
</Button>
</Item>
</Form>
<Divider>Password</Divider>
<Form
validateMessages={validateMessages}
form={formPassword}
onFinish={onUpdatePassword}
>
<Item
name="oldPassword"
rules={requiredRules}
hasFeedback
>
<Input.Password
placeholder="Old password"
autoComplete="current-password"
prefix={<LockOutlined />}
/>
</Item>
<Item
name="password"
rules={passwordRules}
hasFeedback
>
<Input.Password
placeholder="New password"
autoComplete="new-password"
prefix={<LockOutlined />}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
icon={<LockOutlined />}
loading={isPasswordLoading}
>
Change
</Button>
</Item>
</Form>
</div>
<div className="large-container">
<Divider>Active sessions</Divider>
<List
size="small"
bordered
dataSource={sessions || []}
renderItem={item => <List.Item>{item}</List.Item>}
loading={sessions === null}
/>
<Divider>Audit logs</Divider>
<List
size="small"
bordered
dataSource={logs || []}
renderItem={item => <List.Item>{item}</List.Item>}
loading={logs === null}
/>
</div>
</>
</Space>
</div>
);
};

View File

@ -35,7 +35,6 @@ const ResetPassword = () => {
navigate(Routes.LOGIN);
} catch (error) {
form.resetFields();
console.warn(error);
resetFailed(error as Error);
setIsLoading(false);
}

View File

@ -5,14 +5,11 @@ import {
Input,
} from 'antd';
import { LockOutlined } from '@ant-design/icons';
import {
useEffect,
useState,
} from 'react';
import { useState } from 'react';
import {
Link,
useMatch,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
@ -28,33 +25,23 @@ const { Item } = Form;
const ResetPasswordConfirmation = () => {
const { confirmResetPassword } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const routeMatch = useMatch(Routes.RESET_PASSWORD_CONFIRMATION);
const changePassword = async ({ password }: { password: string }) => {
setIsLoading(true);
try {
await confirmResetPassword(userId!, secret!, password);
await confirmResetPassword(routeMatch!.params.token!, password);
passwordUpdateSuccess();
navigate(Routes.LOGIN);
} catch (error) {
console.warn(error);
passwordUpdateFailed(error as Error);
form.resetFields();
setIsLoading(false);
}
};
useEffect(() => {
if (!userId || !secret) {
passwordUpdateFailed(new Error('Invalid URL'));
navigate(Routes.HUB);
}
}, [navigate, secret, userId]);
return (
<div className="auth-container">
<Divider>Change password</Divider>

View File

@ -1,237 +0,0 @@
import {
useCallback,
useEffect,
useState,
} from 'react';
import {
Form,
Input,
Button,
Divider,
Space,
} from 'antd';
import {
MailOutlined,
LockOutlined,
UserOutlined,
GoogleOutlined,
GithubOutlined,
FacebookOutlined,
UserAddOutlined,
} from '@ant-design/icons';
import {
Link,
useNavigate,
} from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import validateMessages from './validateMessages';
import {
emailNotVerified,
signUpFailed,
magicLinkSent,
signUpSuccessful,
} from './notifications';
import {
emailRules,
passwordRules,
requiredRules,
} from '../../utils/form';
const { Item } = Form;
const SignUp = () => {
const [formMagicLink] = Form.useForm();
const [formEmail] = Form.useForm();
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
const {
currentUser,
signUp,
sendEmailVerification,
googleAuth,
githubAuth,
facebookAuth,
sendMagicLink,
} = useAuth();
const navigate = useNavigate();
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
const googleLogin = useCallback(async () => {
setIsGoogleLoading(true);
try {
await googleAuth();
} catch (error) {
signUpFailed(error as Error);
}
}, [googleAuth]);
const githubLogin = useCallback(async () => {
setIsGithubLoading(true);
try {
await githubAuth();
} catch (error) {
signUpFailed(error as Error);
}
}, [githubAuth]);
const facebookLogin = useCallback(async () => {
setIsFacebookLoading(true);
try {
await facebookAuth();
} catch (error) {
signUpFailed(error as Error);
}
}, [facebookAuth]);
const emailSignUp = async ({ email, password, username }: { email: string, password: string, username: string }) => {
setIsEmailLoading(true);
try {
const user = await signUp(email, password, username);
await sendEmailVerification();
signUpSuccessful();
if (!user.emailVerification) {
emailNotVerified();
}
navigate(Routes.HUB);
} catch (error) {
console.warn(error);
signUpFailed(error as Error);
formMagicLink.resetFields();
formEmail.resetFields();
setIsEmailLoading(false);
}
};
const magicLinkLogin = async ({ email }: { email: string }) => {
setIsMagicLinkLoading(true);
try {
await sendMagicLink(email);
magicLinkSent();
} catch (error) {
signUpFailed(error as Error);
} finally {
setIsMagicLinkLoading(false);
formMagicLink.resetFields();
formEmail.resetFields();
}
};
useEffect(() => {
if (currentUser) {
navigate(Routes.HUB);
}
}, [currentUser, navigate]);
return (
<div className="auth-container">
<Divider>Sign Up</Divider>
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
<Button
loading={isGoogleLoading}
onClick={googleLogin}
disabled={isAnythingLoading}
>
<GoogleOutlined />Google
</Button>
<Button
loading={isGithubLoading}
onClick={githubLogin}
disabled={isAnythingLoading}
>
<GithubOutlined />GitHub
</Button>
<Button
loading={isFacebookLoading}
onClick={facebookLogin}
disabled={isAnythingLoading}
>
<FacebookOutlined />Facebook
</Button>
</Space>
<Divider />
<Form
onFinish={magicLinkLogin}
validateMessages={validateMessages}
form={formMagicLink}
>
<Item name="email" rules={emailRules} hasFeedback>
<Input
prefix={<MailOutlined />}
placeholder="Email"
id="email-magic-link"
autoComplete="email"
disabled={isAnythingLoading}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isMagicLinkLoading}
disabled={isAnythingLoading}
icon={<MailOutlined />}
>
Send me a Magic Link
</Button>
</Item>
</Form>
<Form
onFinish={emailSignUp}
validateMessages={validateMessages}
form={formEmail}
>
<Divider />
<Item name="username" rules={requiredRules} hasFeedback>
<Input
prefix={<UserOutlined />}
placeholder="Username"
autoComplete="name"
disabled={isAnythingLoading}
/>
</Item>
<Item name="email" rules={emailRules} hasFeedback>
<Input
prefix={<MailOutlined />}
placeholder="Email"
autoComplete="email"
disabled={isAnythingLoading}
/>
</Item>
<Item
name="password"
rules={passwordRules}
hasFeedback
>
<Input.Password
placeholder="Password"
autoComplete="new-password"
prefix={<LockOutlined />}
disabled={isAnythingLoading}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isEmailLoading}
disabled={isAnythingLoading}
icon={<UserAddOutlined />}
>
Sign Up using password
</Button>
</Item>
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
Log In
</Link>
</Form>
</div>
);
};
export default SignUp;

View File

@ -13,9 +13,9 @@ const emailNotVerified = () => notification.warning({
...baseOptions,
});
const magicLinkSent = () => notification.success({
message: 'Check your email',
description: 'Magic link sent!',
const usernameNotSet = () => notification.warning({
message: 'Update your profile',
description: 'Your username has to be set before you can upload files!',
...baseOptions,
});
@ -33,7 +33,6 @@ const signUpFailed = (err: Error) => notification.error({
const logInSuccessful = () => notification.success({
message: 'Log in successful',
description: 'Welcome back!',
...baseOptions,
});
@ -51,13 +50,6 @@ const restrictedPage = () => notification.error({
const logOutSuccessful = () => notification.success({
message: 'Log out successful',
description: 'See you next time!',
...baseOptions,
});
const logOutFailed = (err: Error) => notification.error({
message: 'Log out failed',
description: err.message,
...baseOptions,
});
@ -73,12 +65,6 @@ const resetFailed = (err: Error) => notification.error({
...baseOptions,
});
const magicLinkConfirmationFailed = (err: Error) => notification.error({
message: 'Magic Link is invalid',
description: err.message,
...baseOptions,
});
const sendingEmailVerificationFailed = (err: Error) => notification.success({
message: 'Sending verification email failed',
description: err.message,
@ -140,17 +126,15 @@ const copiedToClipboard = () => notification.success({
export {
emailNotVerified,
magicLinkSent,
usernameNotSet,
signUpSuccessful,
signUpFailed,
logInSuccessful,
logInFailed,
restrictedPage,
logOutSuccessful,
logOutFailed,
resetSuccessful,
resetFailed,
magicLinkConfirmationFailed,
sendingEmailVerificationFailed,
emailVerificationSent,
emailVerificationFailed,

24
src/pocketbase.ts Normal file
View File

@ -0,0 +1,24 @@
import PocketBase from 'pocketbase';
import { fetchEnv } from './utils/env';
const API_URL = fetchEnv('VITE_POCKETBASE_API_URL');
const client = new PocketBase(API_URL);
const formatError = (error: any) => {
const { data, message } = error;
if (data.data) {
const errors = Object.keys(data.data).map((key) => `${key.toUpperCase()}: ${data.data[key].message}`);
if (errors.length > 0) {
return errors.join(', ');
}
}
return message;
};
export {
API_URL,
client,
formatError,
};

View File

@ -19,11 +19,9 @@ export enum Routes {
SIGN_UP = '/auth/sign-up',
FORGOT_PASSWORD = '/auth/forgot-password',
RESET_PASSWORD = '/auth/reset-password',
MAGIC_LINK_CONFIRMATION = '/auth/magic-link-confirmation',
EMAIL_VERIFICATION = '/auth/email-verification',
RESET_PASSWORD_CONFIRMATION = '/auth/reset-password-confirmation',
RESET_PASSWORD_CONFIRMATION = '/auth/reset-password-confirmation/:token',
EMAIL_VERIFICATION = '/auth/email-verification/:token',
OAUTH_CALLBACK = '/auth/oauth-callback/:provider',
REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION = 'magic-link-confirmation',
REDIRECT_PAGE_EMAIL_VERIFICATION = 'email-verification',
REDIRECT_PAGE_RESET_PASSWORD = 'reset-password',
REDIRECT_PAGE_OAUTH_CALLBACK = 'oauth',
}

View File

@ -1,57 +1,15 @@
import { Models } from 'appwrite';
import { Record } from 'pocketbase';
import {
ProfilesRecord,
TunesRecord,
} from '../@types/pocketbase-types';
type Partial<T> = {
[A in keyof T]?: T[A];
};
export interface TuneDataDetails {
readme?: string | null;
make?: string | null;
model?: string | null;
displacement?: number | null;
year?: number | null;
hp?: number | null;
stockHp?: number | null;
engineCode?: string | null;
cylindersCount?: number | null;
aspiration?: string | null;
fuel?: string | null;
injectorsSize?: number | null;
coils?: string | null;
}
export type TunesRecordPartial = Partial<TunesRecord>;
export interface TuneDbData {
userId: string;
tuneId: string;
signature: string;
tuneFileId?: string | null;
logFileIds?: string[];
toothLogFileIds?: string[];
customIniFileId?: string | null;
vehicleName: string | null;
engineMake: string | null;
engineCode: string | null;
displacement: number | null;
cylindersCount: number | null;
aspiration: 'na' | 'turbocharged' | 'supercharged';
compression?: number | null;
fuel?: string | null;
ignition?: string | null;
injectorsSize?: number | null;
year?: number | null;
hp?: number | null;
stockHp?: number | null;
readme: string | null;
textSearch?: string | null;
}
export interface TunesRecordFull extends TunesRecord, Record { }
export interface TuneDbDocument extends TuneDbData, Models.Document {}
export type TuneDbDataPartial = Partial<TuneDbData>;
export interface UsersBucket {
userId: string;
bucketId: string;
visibility: 'pubic' | 'private';
createdAt: number;
}
export interface ProfilesRecordFull extends ProfilesRecord, Record { }

View File

@ -3,13 +3,13 @@ import {
Logs,
TuneWithDetails,
} from '@hyper-tuner/types';
import { TuneDbDocument } from './dbData';
import { TunesRecordFull } from './dbData';
export interface ConfigState extends Config {}
export interface TuneState extends TuneWithDetails {}
export interface TuneDataState extends TuneDbDocument {}
export interface TuneDataState extends TunesRecordFull {}
export interface LogsState extends Logs {}

View File

@ -9,26 +9,24 @@ import {
onProgress as onProgressType,
} from './http';
import TuneParser from './tune/TuneParser';
import { TuneDbDocument } from '../types/dbData';
import useServerStorage, { CDN_URL } from '../hooks/useServerStorage';
import { TunesRecordFull } from '../types/dbData';
// TODO: refactor this!!
export const loadTune = async (tuneData: TuneDbDocument | null, bucketId: string) => {
export const loadTune = async (tuneData: TunesRecordFull | null) => {
if (tuneData === null) {
store.dispatch({ type: 'config/load', payload: null });
store.dispatch({ type: 'tune/load', payload: null });
return;
}
const pako = await import('pako');
// eslint-disable-next-line react-hooks/rules-of-hooks
const { getFileForDownload, getINIFile } = useServerStorage();
const { getINIFile, fetchFileFromServer } = useServerStorage();
const started = new Date();
const tuneRaw = await getFileForDownload(tuneData.tuneFileId!, bucketId);
const tuneRaw = await fetchFileFromServer(tuneData.id, tuneData.tuneFile);
const tuneParser = new TuneParser()
.parse(pako.inflate(new Uint8Array(tuneRaw)));
const tuneParser = new TuneParser().parse(tuneRaw);
if (!tuneParser.isValid()) {
console.error('Invalid tune');
@ -38,9 +36,8 @@ export const loadTune = async (tuneData: TuneDbDocument | null, bucketId: string
}
const tune = tuneParser.getTune();
const iniRaw = tuneData.customIniFileId ? getFileForDownload(tuneData.customIniFileId, bucketId) : getINIFile(tuneData.signature);
const buff = pako.inflate(new Uint8Array(await iniRaw));
const config = new INI(buff).parse().getResults();
const iniRaw = tuneData.customIniFile ? fetchFileFromServer(tuneData.id, tuneData.customIniFile) : getINIFile(tuneData.signature);
const config = new INI(await iniRaw).parse().getResults();
// override / merge standard dialogs, constants and help
config.dialogs = {

View File

@ -2,14 +2,20 @@ import { Rule } from 'antd/lib/form';
const REQUIRED_MESSAGE = 'This field is required';
// eslint-disable-next-line import/prefer-default-export
export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
const usernamePattern = /^[A-z][A-z0-9_\\-]{3,30}$/;
export const passwordRules: Rule[] = [
{ required: true },
{ pattern: passwordPattern, message: 'Password is too weak!' },
];
export const usernameRules: Rule[] = [
{ required: true },
{ pattern: usernamePattern, message: 'Username has invalid format!' },
];
export const emailRules: Rule[] = [{
required: true,
type: 'email',

View File

@ -6,3 +6,5 @@ export const camelToUrlCase = (str: string) => str
export const snakeToCamelCase = (str: string) => str
.replace(/([-_]\w)/g, (g) => g[1].toUpperCase());
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);

View File

@ -2,5 +2,12 @@ import { fetchEnv } from './env';
export const buildFullUrl = (parts = [] as string[]) => `${fetchEnv('VITE_WEB_URL')}/#${parts.join('/')}`;
export const buildRedirectUrl = (page: string) => `${fetchEnv('VITE_WEB_URL')}?redirectPage=${page}`;
export const buildRedirectUrl = (redirectPage: string, params: { [param: string]: string }) => {
const url = new URL(fetchEnv('VITE_WEB_URL'));
url.search = new URLSearchParams({
redirect: redirectPage,
...params,
}).toString();
return url.toString();
};

View File

@ -17,7 +17,6 @@ export default defineConfig({
sentry: ['@sentry/react', '@sentry/browser', '@sentry/tracing'],
kbar: ['kbar'],
perfectScrollbar: ['perfect-scrollbar'],
appwrite: ['appwrite'],
},
},
},