Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit b69f231

Browse files
authoredJan 15, 2020
Merge pull request #30 from ljosberinn/master
recoverAccount workflow... and a bit more
2 parents 155257e + c33c658 commit b69f231

File tree

5 files changed

+2935
-2330
lines changed

5 files changed

+2935
-2330
lines changed
 

‎package.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
},
2626
"dependencies": {
2727
"gotrue-js": "^0.9.25",
28-
"tsdx": "^0.6.0"
28+
"tsdx": "^0.12.3"
2929
},
3030
"peerDependencies": {
3131
"react": "^15.0.0 || ^16.0.0",
@@ -43,18 +43,18 @@
4343
"trailingComma": "es5"
4444
},
4545
"devDependencies": {
46-
"@svgr/rollup": "^4.2.0",
47-
"@types/jest": "^23.1.5",
48-
"@types/react": "^16.7.13",
49-
"@types/react-dom": "^16.0.11",
50-
"auto-changelog": "^1.13.0",
46+
"@svgr/rollup": "^5.0.1",
47+
"@types/jest": "^24.0.25",
48+
"@types/react": "^16.9.17",
49+
"@types/react-dom": "^16.9.4",
50+
"auto-changelog": "^1.16.2",
5151
"gh-release": "^3.5.0",
52-
"husky": "^2.3.0",
53-
"prettier": "^1.17.1",
54-
"pretty-quick": "^1.11.0",
55-
"react": "^16.8.6",
56-
"react-dom": "^16.8.6",
57-
"typescript": "^3.5.1"
52+
"husky": "^4.0.9",
53+
"prettier": "^1.19.1",
54+
"pretty-quick": "^2.0.1",
55+
"react": "^16.12.0",
56+
"react-dom": "^16.12.0",
57+
"typescript": "^3.7.4"
5858
},
5959
"files": [
6060
"dist"

‎src/index.tsx

Lines changed: 165 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
1-
import React from 'react';
1+
import React, {
2+
useState,
3+
useMemo,
4+
useEffect,
5+
createContext,
6+
useContext,
7+
// types
8+
Dispatch,
9+
SetStateAction,
10+
ReactNode,
11+
useCallback,
12+
} from 'react';
213

3-
import GoTrue, { User, Settings } from 'gotrue-js';
14+
import GoTrue, {
15+
User as GoTrueUser,
16+
Settings as GoTrueSettings,
17+
} from 'gotrue-js';
418
import { runRoutes } from './runRoutes';
19+
import { TokenParam, defaultParam } from './token';
520

621
type authChangeParam = (user?: User) => string | void;
722

8-
export type Settings = Settings;
9-
export type User = User;
23+
export type Settings = GoTrueSettings;
24+
export type User = GoTrueUser;
25+
type Provider = 'bitbucket' | 'github' | 'gitlab' | 'google';
1026

1127
const defaultSettings = {
1228
autoconfirm: false,
@@ -21,29 +37,34 @@ const defaultSettings = {
2137
},
2238
};
2339

40+
const errors = {
41+
noUserFound: 'No current user found - are you logged in?',
42+
noUserTokenFound: 'no user token found',
43+
tokenMissingOrInvalid: 'either no token found or invalid for this purpose',
44+
};
45+
46+
type MaybeUserPromise = Promise<User | undefined>;
47+
2448
export type ReactNetlifyIdentityAPI = {
2549
user: User | undefined;
2650
/** not meant for normal use! you should mostly use one of the other exported methods to update the user instance */
27-
setUser: React.Dispatch<React.SetStateAction<User | undefined>>;
51+
setUser: Dispatch<SetStateAction<User | undefined>>;
2852
isConfirmedUser: boolean;
2953
isLoggedIn: boolean;
3054
signupUser: (
3155
email: string,
3256
password: string,
3357
data: Object
34-
) => Promise<User | undefined>;
58+
) => MaybeUserPromise;
3559
loginUser: (
3660
email: string,
3761
password: string,
3862
remember?: boolean
39-
) => Promise<User | undefined>;
40-
logoutUser: () => Promise<User | undefined>;
63+
) => MaybeUserPromise;
64+
logoutUser: () => MaybeUserPromise;
4165
requestPasswordRecovery: (email: string) => Promise<void>;
42-
recoverAccount: (
43-
token: string,
44-
remember?: boolean | undefined
45-
) => Promise<User>;
46-
updateUser: (fields: { data: object }) => Promise<User | undefined>;
66+
recoverAccount: (remember?: boolean) => MaybeUserPromise;
67+
updateUser: (fields: { data: object }) => MaybeUserPromise;
4768
getFreshJWT: () => Promise<string>;
4869
authedFetch: {
4970
get: (endpoint: string, obj?: {}) => Promise<any>;
@@ -53,14 +74,10 @@ export type ReactNetlifyIdentityAPI = {
5374
};
5475
_goTrueInstance: GoTrue;
5576
_url: string;
56-
loginProvider: (
57-
provider: 'bitbucket' | 'github' | 'gitlab' | 'google'
58-
) => void;
59-
acceptInviteExternalUrl: (
60-
provider: 'bitbucket' | 'github' | 'gitlab' | 'google',
61-
token: string
62-
) => string;
77+
loginProvider: (provider: Provider) => void;
78+
acceptInviteExternalUrl: (provider: Provider) => string;
6379
settings: Settings;
80+
param: TokenParam;
6481
};
6582

6683
const [_useIdentityContext, _IdentityCtxProvider] = createCtx<
@@ -75,7 +92,7 @@ export function IdentityContextProvider({
7592
onAuthChange = () => {},
7693
}: {
7794
url: string;
78-
children: React.ReactNode;
95+
children: ReactNode;
7996
onAuthChange?: authChangeParam;
8097
}) {
8198
/******** SETUP */
@@ -99,7 +116,7 @@ export function useNetlifyIdentity(
99116
onAuthChange: authChangeParam = () => {},
100117
enableRunRoutes: boolean = true
101118
): ReactNetlifyIdentityAPI {
102-
const goTrueInstance = React.useMemo(
119+
const goTrueInstance = useMemo(
103120
() =>
104121
new GoTrue({
105122
APIUrl: `${url}/.netlify/identity`,
@@ -108,87 +125,164 @@ export function useNetlifyIdentity(
108125
[url]
109126
);
110127

111-
const [user, setUser] = React.useState<User | undefined>(
128+
/******* STATE and EFFECTS */
129+
130+
const [user, setUser] = useState<User | undefined>(
112131
goTrueInstance.currentUser() || undefined
113132
);
114-
const _setUser = (_user: User | undefined) => {
115-
setUser(_user);
116-
onAuthChange(_user); // if someone's subscribed to auth changes, let 'em know
117-
return _user; // so that we can continue chaining
118-
};
119133

120-
React.useEffect(() => {
134+
const _setUser = useCallback(
135+
(_user: User | undefined) => {
136+
setUser(_user);
137+
onAuthChange(_user); // if someone's subscribed to auth changes, let 'em know
138+
return _user; // so that we can continue chaining
139+
},
140+
[onAuthChange]
141+
);
142+
143+
const [param, setParam] = useState<TokenParam>(defaultParam);
144+
145+
useEffect(() => {
121146
if (enableRunRoutes) {
122-
runRoutes(goTrueInstance, _setUser);
147+
const param = runRoutes(goTrueInstance, _setUser);
148+
149+
if (param.token || param.error) {
150+
setParam(param);
151+
}
123152
}
124153
}, []);
125154

155+
const [settings, setSettings] = useState<Settings>(defaultSettings);
156+
157+
useEffect(() => {
158+
goTrueInstance.settings
159+
.bind(goTrueInstance)()
160+
.then(x => setSettings(x));
161+
}, []);
162+
126163
/******* OPERATIONS */
127164
// make sure the Registration preferences under Identity settings in your Netlify dashboard are set to Open.
128165
// https://react-netlify-identity.netlify.com/login#access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTY0ODY3MjEsInN1YiI6ImNiZjY5MTZlLTNlZGYtNGFkNS1iOTYzLTQ4ZTY2NDcyMDkxNyIsImVtYWlsIjoic2hhd250aGUxQGdtYWlsLmNvbSIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImdpdGh1YiJ9LCJ1c2VyX21ldGFkYXRhIjp7ImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMxLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY3NjQ5NTc_dj00IiwiZnVsbF9uYW1lIjoic3d5eCJ9fQ.E8RrnuCcqq-mLi1_Q5WHJ-9THIdQ3ha1mePBKGhudM0&expires_in=3600&refresh_token=OyA_EdRc7WOIVhY7RiRw5w&token_type=bearer
129166
/******* external oauth */
130-
type Provider = 'bitbucket' | 'github' | 'gitlab' | 'google';
131167

132-
const loginProvider = (provider: Provider) => {
133-
const url = goTrueInstance.loginExternalUrl(provider);
134-
window.location.href = url;
135-
};
136-
const acceptInviteExternalUrl = (provider: Provider, token: string) =>
137-
goTrueInstance.acceptInviteExternalUrl(provider, token);
138-
const _settings = goTrueInstance.settings.bind(goTrueInstance);
139-
const [settings, setSettings] = React.useState<Settings>(defaultSettings);
140-
React.useEffect(() => {
141-
_settings().then(x => setSettings(x));
142-
}, []);
168+
const loginProvider = useCallback(
169+
(provider: Provider) => {
170+
const url = goTrueInstance.loginExternalUrl(provider);
171+
window.location.href = url;
172+
},
173+
[goTrueInstance]
174+
);
175+
176+
const acceptInviteExternalUrl = useCallback(
177+
(provider: Provider) => {
178+
if (!param.token || param.type !== 'invite') {
179+
throw new Error(errors.tokenMissingOrInvalid);
180+
}
181+
182+
const url = goTrueInstance.acceptInviteExternalUrl(provider, param.token);
183+
// clean up consumed token
184+
setParam(defaultParam);
185+
186+
return url;
187+
},
188+
[goTrueInstance, param]
189+
);
143190

144191
/******* email auth */
145-
const signupUser = (email: string, password: string, data: Object) =>
146-
goTrueInstance.signup(email, password, data).then(_setUser); // TODO: make setUser optional?
147-
const loginUser = (
148-
email: string,
149-
password: string,
150-
remember: boolean = true
151-
) => goTrueInstance.login(email, password, remember).then(_setUser);
152-
const requestPasswordRecovery = (email: string) =>
153-
goTrueInstance.requestPasswordRecovery(email);
154-
const recoverAccount = (token: string, remember?: boolean | undefined) =>
155-
goTrueInstance.recover(token, remember);
156-
const updateUser = (fields: { data: object }) => {
157-
if (user == null) {
158-
throw new Error('No current user found - are you logged in?');
159-
} else {
192+
const signupUser = useCallback(
193+
(
194+
email: string,
195+
password: string,
196+
data: Object,
197+
directLogin: boolean = true
198+
) =>
199+
goTrueInstance.signup(email, password, data).then(user => {
200+
if (directLogin) {
201+
return _setUser(user);
202+
}
203+
204+
return user;
205+
}),
206+
[goTrueInstance, _setUser]
207+
);
208+
209+
const loginUser = useCallback(
210+
(email: string, password: string, remember: boolean = true) =>
211+
goTrueInstance.login(email, password, remember).then(_setUser),
212+
[goTrueInstance, _setUser]
213+
);
214+
215+
const requestPasswordRecovery = useCallback(
216+
(email: string) => goTrueInstance.requestPasswordRecovery(email),
217+
[goTrueInstance]
218+
);
219+
220+
const recoverAccount = useCallback(
221+
(remember?: boolean | undefined) => {
222+
if (!param.token || param.type !== 'recovery') {
223+
throw new Error(errors.tokenMissingOrInvalid);
224+
}
225+
226+
return goTrueInstance.recover(param.token, remember).then(user => {
227+
// clean up consumed token
228+
setParam(defaultParam);
229+
return _setUser(user);
230+
});
231+
},
232+
[goTrueInstance, _setUser, param]
233+
);
234+
235+
const updateUser = useCallback(
236+
(fields: { data: object }) => {
237+
if (!user) {
238+
throw new Error(errors.noUserFound);
239+
}
240+
160241
return user!
161242
.update(fields) // e.g. { data: { email: "example@example.com", password: "password" } }
162243
.then(_setUser);
244+
},
245+
[user]
246+
);
247+
248+
const getFreshJWT = useCallback(() => {
249+
if (!user) {
250+
throw new Error(errors.noUserFound);
163251
}
164-
};
165-
const getFreshJWT = () => {
166-
if (!user) throw new Error('No current user found - are you logged in?');
252+
167253
return user.jwt();
168-
};
169-
const logoutUser = () => {
170-
if (!user) throw new Error('No current user found - are you logged in?');
254+
}, [user]);
255+
256+
const logoutUser = useCallback(() => {
257+
if (!user) {
258+
throw new Error(errors.noUserFound);
259+
}
260+
171261
return user.logout().then(() => _setUser(undefined));
172-
};
262+
}, [user]);
173263

174264
const genericAuthedFetch = (method: string) => (
175265
endpoint: string,
176-
obj = {}
266+
options: RequestInit = {}
177267
) => {
178-
if (!user || !user.token || !user.token.access_token)
179-
throw new Error('no user token found');
268+
if (!user?.token?.access_token) {
269+
throw new Error(errors.noUserTokenFound);
270+
}
271+
180272
const defaultObj = {
181273
headers: {
182274
Accept: 'application/json',
183275
'Content-Type': 'application/json',
184276
Authorization: 'Bearer ' + user.token.access_token,
185277
},
186278
};
187-
const finalObj = Object.assign(defaultObj, { method }, obj);
279+
const finalObj = Object.assign(defaultObj, { method }, options);
280+
188281
return fetch(endpoint, finalObj).then(res =>
189282
finalObj.headers['Content-Type'] === 'application/json' ? res.json() : res
190283
);
191284
};
285+
192286
const authedFetch = {
193287
get: genericAuthedFetch('GET'),
194288
post: genericAuthedFetch('POST'),
@@ -216,6 +310,7 @@ export function useNetlifyIdentity(
216310
loginProvider,
217311
acceptInviteExternalUrl,
218312
settings,
313+
param,
219314
};
220315
}
221316

@@ -234,9 +329,9 @@ function validateUrl(value: string) {
234329

235330
// lazy initialize contexts without providing a Nullable type upfront
236331
function createCtx<A>() {
237-
const ctx = React.createContext<A | undefined>(undefined);
332+
const ctx = createContext<A | undefined>(undefined);
238333
function useCtx() {
239-
const c = React.useContext(ctx);
334+
const c = useContext(ctx);
240335
if (!c) throw new Error('useCtx must be inside a Provider with a value');
241336
return c;
242337
}

‎src/runRoutes.tsx

Lines changed: 62 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,84 @@
11
import GoTrue, { User } from 'gotrue-js';
2+
import { TokenParam, defaultParam } from './token';
23

34
/**
45
* This code runs on every rerender so keep it light
56
* keep checking the current route and do logic based on the route
67
* as dictated by netlify identity's communication with us via hashes
78
*/
89

9-
const routes = /(confirmation|invite|recovery|email_change)_token=([^&]+)/;
10+
const routes = /(confirmation|invite|recovery|email_change|access)_token=([^&]+)/;
1011
const errorRoute = /error=access_denied&error_description=403/;
11-
const accessTokenRoute = /access_token=/;
12-
const confirmationRoute = /confirmation_token=/;
12+
13+
const reduceHashToKeyValue = (hash: string): { [key: string]: string } =>
14+
hash.split('&').reduce((carry, pair) => {
15+
const [key, value] = pair.split('=');
16+
17+
return { ...carry, [key]: value };
18+
}, {});
19+
20+
const hashReplace = /^#\/?/;
1321

1422
export function runRoutes(
1523
gotrue: GoTrue,
1624
setUser: (value: User) => User | undefined,
1725
remember = true
18-
) {
19-
const hash = (document.location.hash || '').replace(/^#\/?/, '');
20-
if (!hash) return; // early terminate if no hash
21-
22-
const m = hash.match(routes);
23-
if (m) {
24-
// store.verifyToken(m[1], m[2]);
25-
document.location.hash = '';
26+
): TokenParam {
27+
// early terminate if no hash
28+
// also accounts for document.cookie further down
29+
if (!document?.location?.hash) {
30+
return defaultParam;
2631
}
2732

28-
const em = hash.match(errorRoute);
29-
if (em) {
30-
// store.openModal("signup");
31-
document.location.hash = '';
33+
const hash = document.location.hash.replace(hashReplace, '');
34+
35+
// todo: maybe replace with history.replaceState to completely clear the url?
36+
// currently keeps #
37+
document.location.hash = '';
38+
39+
// earliest possible bail on any match
40+
if (hash.match(errorRoute)) {
41+
return {
42+
...defaultParam,
43+
error: 'access_denied',
44+
status: 403,
45+
};
3246
}
33-
const params = {} as { [key: string]: string };
34-
hash.split('&').forEach(pair => {
35-
const [key, value] = pair.split('=');
36-
params[key] = value;
37-
});
3847

39-
const am = hash.match(accessTokenRoute);
40-
if (am) {
41-
if (!!document && params['access_token']) {
42-
document.cookie = `nf_jwt=${params['access_token']}`;
48+
const matchesActionHashes = hash.match(routes);
49+
50+
if (matchesActionHashes) {
51+
const params = reduceHashToKeyValue(hash);
52+
53+
if (params.confirmation_token) {
54+
gotrue
55+
.confirm(params.confirmation_token)
56+
.then(setUser)
57+
.catch(console.error);
58+
59+
// dont notify dev as this package does not export its own method for this
60+
return defaultParam;
4361
}
44-
document.location.hash = '';
45-
// store.openModal("login");
46-
// store.completeExternalLogin(params);
47-
gotrue
48-
.createUser(params, remember)
49-
.then(setUser)
50-
.catch(console.error);
51-
}
5262

53-
const cm = hash.match(confirmationRoute);
54-
if (cm) {
55-
document.location.hash = '';
56-
// store.openModal("login");
57-
// store.completeExternalLogin(params);
58-
gotrue
59-
.confirm(params['confirmation_token'])
60-
.then(setUser)
61-
.catch(console.error);
63+
if (params.access_token) {
64+
document.cookie = `nf_jwt=${params.access_token}`;
65+
66+
gotrue
67+
.createUser(params, remember)
68+
.then(setUser)
69+
.catch(console.error);
70+
71+
// also dont notify dev here for the same reasons as above
72+
return defaultParam;
73+
}
74+
75+
// pass responsibility to dev in all other cases
76+
return {
77+
...defaultParam,
78+
type: matchesActionHashes[1] as TokenParam['type'],
79+
token: matchesActionHashes[2],
80+
};
6281
}
82+
83+
return defaultParam;
6384
}

‎src/token.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type TokenParam = {
2+
token: string | undefined;
3+
type: 'invite' | 'recovery' | 'email_change' | undefined;
4+
error?: 'access_denied';
5+
status?: 403;
6+
};
7+
8+
export const defaultParam: TokenParam = {
9+
token: undefined,
10+
type: undefined,
11+
error: undefined,
12+
status: undefined,
13+
};

‎yarn.lock

Lines changed: 2683 additions & 2207 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.