Slightly improve on-boarding flow.

This commit is contained in:
Gareth Latty
2020-10-03 21:04:43 +01:00
parent 2e4e10eb0f
commit 350b534ff6
11 changed files with 251 additions and 170 deletions
+7 -2
View File
@@ -34,12 +34,17 @@ things or time of others, and be patient where people can't find time.
### Advice for making good pull requests
- Don't be afraid to create work-in-progress pull requests to get feedback from others if you are not sure on the best
way to solve a problem or get stuck.
- Comment on an existing issue or make a new one for what you plan on doing, so others know you are working on it.
- Please make sure you run the current version of prettier installed in the project on any files you change to
ensure they have the project code style. The easiest way to do this is to set it up with your editor to run
automatically, see above.
- Don't be afraid to create work-in-progress pull requests to get feedback from others if you are not sure on the best
way to solve a problem or get stuck.
- If you are installing additional dependencies, please try to keep them minimal (e.g: don't install giant frameworks
for one or two small features) and check they don't have vulnerabilities and are trustworthy projects.
- Try to keep a clean commit history. Make commits reasonably-sized, self-contained changes that are well described by
their commit messages. Git has powerful tools for editing your commit history, so don't feel constrained by this
while developing, but remember you can go back with amend commits and interactive rebases to clean it up later.
If there are changes while you are working, please rebase on the new head rather than merging. If you are unsure
about any of this (git can be intimidating), feel free to just commit up a PR as-is and ask for help.
+25 -19
View File
@@ -45,7 +45,7 @@ getLanguages : (List String -> Msg) -> Cmd Msg
getLanguages handleSuccess =
Http.get
{ url = apiUrl [ "languages" ]
, expect = expectJsonOrError handleSuccess (Json.Decode.list Json.Decode.string)
, expect = expectJsonOrError (Tuple.second >> handleSuccess) (Json.Decode.list Json.Decode.string)
}
@@ -53,16 +53,20 @@ getAuthMethods : (Auth.Methods -> Msg) -> Cmd Msg
getAuthMethods handleSuccess =
Http.get
{ url = apiUrl [ "auth" ]
, expect = expectJsonOrError handleSuccess Auth.decode
, expect = expectJsonOrError (Tuple.second >> handleSuccess) Auth.decode
}
signIn : Json.Value -> (Auth -> Msg) -> Cmd Msg
signIn : Json.Value -> (Bool -> Auth -> Msg) -> Cmd Msg
signIn authPayload handleSuccess =
let
toMsg ( statusCode, auth ) =
handleSuccess (statusCode == 201) auth
in
Http.post
{ url = apiUrl [ "users" ]
, body = authPayload |> Http.jsonBody
, expect = expectJsonOrError handleSuccess Auth.decoder
, expect = expectJsonOrError toMsg Auth.decoder
}
@@ -70,7 +74,7 @@ getDeck : Deck.Code -> (Deck -> Msg) -> Cmd Msg
getDeck code toMsg =
Http.get
{ url = apiUrl [ "decks", code |> Deck.codeToString ]
, expect = expectJsonOrError toMsg Deck.decode
, expect = expectJsonOrError (Tuple.second >> toMsg) Deck.decode
}
@@ -88,7 +92,7 @@ getDecks id token toMsg =
Http.post
{ url = apiUrl [ "decks", "by", id ]
, body = body |> Json.object |> Http.jsonBody
, expect = expectJsonOrError toMsg (Decks.codeAndSummaryDecoder |> Json.Decode.list)
, expect = expectJsonOrError (Tuple.second >> toMsg) (Decks.codeAndSummaryDecoder |> Json.Decode.list)
}
@@ -103,7 +107,7 @@ browseDecks { page, language, search } toMsg =
in
Http.get
{ url = queryApiUrl [ "decks", "browse" ] (queries |> List.filterMap identity)
, expect = expectJsonOrError toMsg (Decks.codeAndSummaryDecoder |> Json.Decode.list)
, expect = expectJsonOrError (Tuple.second >> toMsg) (Decks.codeAndSummaryDecoder |> Json.Decode.list)
}
@@ -117,7 +121,7 @@ createDeck token d toMsg =
]
|> Json.object
|> Http.jsonBody
, expect = expectJsonOrError toMsg Deck.codeDecoder
, expect = expectJsonOrError (Tuple.second >> toMsg) Deck.codeDecoder
}
@@ -128,7 +132,7 @@ deleteDeck token code toMsg =
, headers = []
, url = apiUrl [ "decks", code |> Deck.codeToString ]
, body = [ ( "token", token |> Json.string ) ] |> Json.object |> Http.jsonBody
, expect = expectJsonOrError toMsg Deck.codeDecoder
, expect = expectJsonOrError (Tuple.second >> toMsg) Deck.codeDecoder
, timeout = Nothing
, tracker = Nothing
}
@@ -146,7 +150,7 @@ save token code patch toMsg =
]
|> Json.object
|> Http.jsonBody
, expect = expectJsonOrError toMsg Deck.decode
, expect = expectJsonOrError (Tuple.second >> toMsg) Deck.decode
, timeout = Nothing
, tracker = Nothing
}
@@ -174,7 +178,7 @@ saveProfile token name toMsg =
]
|> Json.object
|> Http.jsonBody
, expect = expectJsonOrError toMsg Auth.decoder
, expect = expectJsonOrError (Tuple.second >> toMsg) Auth.decoder
}
@@ -185,17 +189,19 @@ deleteProfile token toMsg =
, headers = []
, url = apiUrl [ "users" ]
, body = [ ( "token", token |> Json.string ) ] |> Json.object |> Http.jsonBody
, expect = expectJsonOrError toMsg (Json.Decode.succeed ())
, expect = expectJsonOrError (Tuple.second >> toMsg) (Json.Decode.succeed ())
, timeout = Nothing
, tracker = Nothing
}
expectJsonOrError : (value -> Msg) -> Json.Decode.Decoder value -> Http.Expect Msg
expectJsonOrError : (( Int, value ) -> Msg) -> Json.Decode.Decoder value -> Http.Expect Msg
expectJsonOrError toMsg decoder =
let
onGoodStatus =
Json.Decode.decodeString decoder >> Result.mapError (Error.BadResponse >> Error.Application)
onGoodStatus statusCode =
Json.Decode.decodeString decoder
>> Result.map (Tuple.pair statusCode)
>> Result.mapError (Error.BadResponse >> Error.Application)
onBadStatus body =
case body |> Json.Decode.decodeString Error.decode of
@@ -210,11 +216,11 @@ expectJsonOrError toMsg decoder =
expectBytesOrError : (Bytes -> Msg) -> Http.Expect Msg
expectBytesOrError toMsg =
handle Ok (Error.ServerError |> Error.Application |> always)
handle (always Ok) (Error.ServerError |> Error.Application |> always)
|> Http.expectBytesResponse (toMsgHandlingErrors toMsg)
handle : (body -> Result Error value) -> (body -> Error) -> Http.Response body -> Result Error value
handle : (Int -> body -> Result Error value) -> (body -> Error) -> Http.Response body -> Result Error value
handle onGoodStatus onBadStatus response =
case response of
Http.BadUrl_ url ->
@@ -233,8 +239,8 @@ handle onGoodStatus onBadStatus response =
else
onBadStatus body |> Err
Http.GoodStatus_ _ body ->
onGoodStatus body
Http.GoodStatus_ { statusCode } body ->
onGoodStatus statusCode body
toMsgHandlingErrors : (value -> Msg) -> Result Error value -> Msg
@@ -27,6 +27,12 @@ view viewing { browserLanguage, auth, decks, knownLanguages } =
renderedDecks =
case decks of
Just [] ->
Html.div [ HtmlA.id "no-decks" ]
[ Icon.ghost |> Icon.viewIcon
, Html.p [] [ Html.text "You haven't made any decks yet, create or upload one below." ]
]
Just d ->
d |> List.map deck |> HtmlK.ul [ HtmlA.class "deck-list" ]
+12 -4
View File
@@ -36,19 +36,27 @@ update msg model =
( model, Ports.tryGoogleAuth method.id )
GoogleAuthResult code ->
( model, Api.signIn (Google.authPayload code) (SetAuth >> Global.LoginMsg) )
( model, Api.signIn (Google.authPayload code) (\isNewAccount auth -> SetAuth isNewAccount auth |> Global.LoginMsg) )
TryGuestSignIn _ ->
( model, Api.signIn Guest.authPayload (SetAuth >> Global.LoginMsg) )
( model, Api.signIn Guest.authPayload (\isNewAccount auth -> SetAuth isNewAccount auth |> Global.LoginMsg) )
TryTwitchSignIn method ->
( model, method |> Twitch.requestUrl model.origin |> Navigation.load )
SetAuth auth ->
SetAuth isNewAccount auth ->
let
page =
if isNewAccount then
Profile
else
auth.id |> Decks.List |> Decks
in
( { model | auth = Just auth, usernameField = auth.name }
, Cmd.batch
[ auth |> Just |> Auth.store
, Route.redirectTo model.navKey (auth.id |> Decks.List |> Decks)
, Route.redirectTo model.navKey page
]
)
@@ -13,5 +13,5 @@ type Msg
| GoogleAuthResult String
| TryGuestSignIn Guest.Method
| TryTwitchSignIn Twitch.Method
| SetAuth Auth
| SetAuth Bool Auth
| SignOut
+1 -1
View File
@@ -37,7 +37,7 @@ onRouteChanged route oldModel =
Twitch.authPayload frag
signIn payload =
Api.signIn payload (Login.SetAuth >> LoginMsg)
Api.signIn payload (\isNewAccount auth -> Login.SetAuth isNewAccount auth |> LoginMsg)
in
( model
, parsedPayload
+10
View File
@@ -84,3 +84,13 @@ ul.deck-list {
}
}
}
#no-decks {
display: flex;
opacity: 0.6;
align-items: center;
p {
margin-left: 0.5em;
}
}
+40 -25
View File
@@ -13,13 +13,13 @@ import * as Store from "./store";
import * as Auth from "./user/auth";
import { default as GoogleAuth } from "google-auth-library";
import * as Uuid from "uuid";
// @ts-ignore
import { default as Zip } from "express-easy-zip";
import * as Errors from "./errors";
import { default as Jwks } from "jwks-rsa";
import * as util from "util";
import { default as Jwt } from "jsonwebtoken";
import { AuthFailure } from "./errors";
import { CreatedOrFoundUser } from "./store";
interface TwitchClaims {
sub: string;
@@ -55,8 +55,8 @@ const main = async (): Promise<void> => {
config.auth.twitch === undefined
? undefined
: Jwks({
jwksUri: config.auth.twitch.jwk,
});
jwksUri: config.auth.twitch.jwk,
});
const verifyWith = async (
token: string,
@@ -67,13 +67,15 @@ const main = async (): Promise<void> => {
const key = await getPubKey(header.kid as string);
return key.getPublicKey();
};
const decoded = Jwt.decode(token, { complete: true });
if (decoded === null || !decoded.hasOwnProperty("header")) {
const decoded = Jwt.decode(token, { complete: true }) as {
header: Jwt.JwtHeader;
};
if (decoded !== null && decoded.hasOwnProperty("header")) {
const key = await keyFromHeader(decoded.header);
return Jwt.verify(token, key, { algorithms: ["RS256"] }) as TwitchClaims;
} else {
throw new AuthFailure();
}
// @ts-ignore
const key = await keyFromHeader(decoded.header);
return Jwt.verify(token, key, { algorithms: ["RS256"] }) as TwitchClaims;
};
const app = express();
@@ -103,6 +105,23 @@ const main = async (): Promise<void> => {
});
app.post("/api/users", async (req, res) => {
function foundOrCreatedUser({
result,
user: { id, name },
}: Store.CreatedOrFoundUser) {
switch (result) {
case "Created":
res.status(HttpStatus.CREATED);
break;
case "Found":
res.status(HttpStatus.OK);
break;
default:
Store.unknownResult(result);
break;
}
res.json({ token: auth.sign({ sub: id }), name });
}
if (req.body.token !== undefined) {
const claims = auth.validate(req.body.token);
const id = claims.sub;
@@ -120,33 +139,29 @@ const main = async (): Promise<void> => {
});
const payload = ticket.getPayload();
if (payload !== undefined) {
const { id, name } = await store.findOrCreateGoogleUser(
payload.sub,
payload.name
foundOrCreatedUser(
await store.findOrCreateGoogleUser(payload.sub, payload.name)
);
res.json({ token: auth.sign({ sub: id }), name });
return;
}
} else if (req.body.twitch) {
if (twitch === undefined || config.auth.twitch === undefined) {
throw new Errors.AuthFailure();
}
const claims = await verifyWith(req.body.twitch.id, twitch);
const { id, name } = await store.findOrCreateTwitchUser(
claims.sub,
claims.preferred_username
foundOrCreatedUser(
await store.findOrCreateTwitchUser(
claims.sub,
claims.preferred_username
)
);
res.json({ token: auth.sign({ sub: id }), name });
return;
} else if (req.body.guest) {
if (config.auth.guest === undefined) {
throw new Errors.AuthFailure();
}
const { id, name } = await store.findOrCreateGuestUser();
res.json({ token: auth.sign({ sub: id }), name });
return;
foundOrCreatedUser(await store.findOrCreateGuestUser());
} else {
throw new Errors.AuthFailure();
}
throw new Errors.AuthFailure();
});
app.delete("/api/users", async (req, res) => {
@@ -204,7 +219,6 @@ const main = async (): Promise<void> => {
app.post("/api/backup", async (req, res) => {
const claims = auth.validate(req.body.token);
const files = await store.getDecks(claims.sub);
// @ts-ignore
res.zip({
files: files.map((c) => {
const d: any = c;
@@ -213,8 +227,9 @@ const main = async (): Promise<void> => {
d.author = d.author.name;
return {
content: Json5.stringify(c, undefined, 2),
name: `${d.name.replace(/\s/g, "-")}-${d.language
}-${Uuid.v4()}.deck.json5`,
name: `${d.name.replace(/\s/g, "-")}-${
d.language
}-${Uuid.v4()}.deck.json5`,
};
}),
filename: "backup.zip",
+128 -116
View File
@@ -8,8 +8,21 @@ import * as Errors from "./errors";
import * as uuid from "uuid";
import * as User from "./user";
import * as Code from "./deck/code";
import { uniqueNamesGenerator, Config, adjectives, colors, animals } from 'unique-names-generator';
import { Console } from "winston/lib/winston/transports";
import {
uniqueNamesGenerator,
adjectives,
colors,
animals,
} from "unique-names-generator";
export interface CreatedOrFoundUser {
result: "Created" | "Found";
user: User.User;
}
export function unknownResult(result: never): never {
throw new Error(`Unknown result: "${result}".`);
}
const migrateConfig = {
logger: (msg: string) => Logging.logger.info(msg),
@@ -26,8 +39,8 @@ export const init = async (config: Pg.PoolConfig): Promise<Store> => {
const db = config.database
? config.database
: config.user
? config.user
: "manydecks";
? config.user
: "manydecks";
await Migrate.createDb(db, { client }, migrateConfig);
await Migrate.migrate({ client }, "src/sql", migrateConfig);
@@ -70,64 +83,66 @@ export class Store {
generateUsername(): string {
return uniqueNamesGenerator({
dictionaries: [adjectives, colors, animals]
dictionaries: [adjectives, colors, animals],
});
}
public findOrCreateGoogleUser: (
googleId: string,
googleName?: string
) => Promise<User.User> = async (googleId, googleName) =>
await this.withClient(async (client) => {
const existingResult = await client.query(
`SELECT id, name FROM manydecks.users WHERE google_id = $1;`,
[googleId]
) => Promise<CreatedOrFoundUser> = async (googleId, googleName) =>
await this.withClient(async (client) => {
const existingResult = await client.query(
`SELECT id, name FROM manydecks.users WHERE google_id = $1;`,
[googleId]
);
if (existingResult.rowCount > 0) {
const [user] = existingResult.rows;
return { result: "Found", user: { id: user.id, name: user.name } };
} else {
const newId = User.id();
const newName =
googleName === undefined ? this.generateUsername() : googleName;
await client.query(
`INSERT INTO manydecks.users (id, name, google_id) VALUES ($1, $2, $3);`,
[newId, newName, googleId]
);
if (existingResult.rowCount > 0) {
const [user] = existingResult.rows;
return { id: user.id, name: user.name };
} else {
const newId = User.id();
const newName = googleName === undefined ? this.generateUsername() : googleName;
await client.query(
`INSERT INTO manydecks.users (id, name, google_id) VALUES ($1, $2, $3);`,
[newId, newName, googleId]
);
return { id: newId, name: newName };
}
});
return { result: "Created", user: { id: newId, name: newName } };
}
});
public findOrCreateTwitchUser: (
twitchId: string,
twitchName?: string
) => Promise<User.User> = async (twitchId, twitchName) =>
await this.withClient(async (client) => {
const existingResult = await client.query(
`SELECT id, name FROM manydecks.users WHERE twitch_id = $1;`,
[twitchId]
) => Promise<CreatedOrFoundUser> = async (twitchId, twitchName) =>
await this.withClient(async (client) => {
const existingResult = await client.query(
`SELECT id, name FROM manydecks.users WHERE twitch_id = $1;`,
[twitchId]
);
if (existingResult.rowCount > 0) {
const [user] = existingResult.rows;
return { result: "Found", user: { id: user.id, name: user.name } };
} else {
const newId = User.id();
const newName =
twitchName === undefined ? this.generateUsername() : twitchName;
await client.query(
`INSERT INTO manydecks.users (id, name, twitch_id) VALUES ($1, $2, $3);`,
[newId, newName, twitchId]
);
if (existingResult.rowCount > 0) {
const [user] = existingResult.rows;
return { id: user.id, name: user.name };
} else {
const newId = User.id();
const newName = twitchName === undefined ? this.generateUsername() : twitchName;
await client.query(
`INSERT INTO manydecks.users (id, name, twitch_id) VALUES ($1, $2, $3);`,
[newId, newName, twitchId]
);
return { id: newId, name: newName };
}
});
return { result: "Created", user: { id: newId, name: newName } };
}
});
public findOrCreateGuestUser: () => Promise<User.User> = async () =>
public findOrCreateGuestUser: () => Promise<CreatedOrFoundUser> = async () =>
await this.withClient(async (client) => {
const existingResult = await client.query(
`SELECT id, name FROM manydecks.users WHERE is_guest;`
);
if (existingResult.rowCount > 0) {
const [user] = existingResult.rows;
return { id: user.id, name: user.name };
return { result: "Found", user: { id: user.id, name: user.name } };
} else {
const newId = User.id();
const newName = this.generateUsername();
@@ -135,7 +150,7 @@ export class Store {
`INSERT INTO manydecks.users (id, name, is_guest) VALUES ($1, $2, True);`,
[newId, newName]
);
return { id: newId, name: newName };
return { result: "Created", user: { id: newId, name: newName } };
}
});
@@ -159,27 +174,24 @@ export class Store {
d: Deck.EditableDeck,
user: string
) => Promise<number> = async (d, user) =>
await this.withClient(async (client) => {
let deck;
try {
deck = Deck.validate(d);
} catch (error) {
throw new Errors.BadDeck();
}
const insert = await client.query(
`
await this.withClient(async (client) => {
let deck;
try {
deck = Deck.validate(d);
} catch (error) {
throw new Errors.BadDeck();
}
const insert = await client.query(
`
INSERT INTO manydecks.decks (deck, author) VALUES ($1, $2) RETURNING id;
`,
[deck, user]
);
const [inserted] = insert.rows;
return inserted.id;
});
[deck, user]
);
const [inserted] = insert.rows;
return inserted.id;
});
public getDeck: (id: number, user?: string) => Promise<Deck.Deck> = async (
id,
user = undefined
) =>
public getDeck: (id: number) => Promise<Deck.Deck> = async (id) =>
await this.withClient(async (client) => {
const meta = await client.query(
`
@@ -254,7 +266,7 @@ export class Store {
};
});
private static * summariesFromRows(
private static *summariesFromRows(
result: Pg.QueryResult
): Iterable<Deck.CodeAndSummary> {
for (const deck of result.rows) {
@@ -284,42 +296,42 @@ export class Store {
user,
publicOnly = true
) =>
await this.withClient(async (client) => {
const result = await client.query(
`
await this.withClient(async (client) => {
const result = await client.query(
`
SELECT id, name, author_id, author, language, calls, responses, public, version
FROM manydecks.summaries WHERE summaries.author_id = $1 AND (NOT $2 OR summaries.public)
ORDER BY id DESC;
`,
[user, publicOnly]
);
return Store.summariesFromRows(result);
});
[user, publicOnly]
);
return Store.summariesFromRows(result);
});
public browse: (
query?: string,
language?: string,
page?: number
) => Promise<Iterable<Deck.CodeAndSummary>> = async (query, language, page) =>
await this.withClient(async (client) => {
const pageSize = 20;
const p = page === undefined ? 0 : page;
const l = language === undefined ? null : language;
let result;
if (query === undefined) {
result = await client.query(
`
await this.withClient(async (client) => {
const pageSize = 20;
const p = page === undefined ? 0 : page;
const l = language === undefined ? null : language;
let result;
if (query === undefined) {
result = await client.query(
`
SELECT id, name, author_id, author, language, calls, responses, public, version
FROM manydecks.summaries
WHERE summaries.public AND ($3::text IS NULL OR summaries.language = $3::text)
ORDER BY id DESC
OFFSET $1::int * $2::int LIMIT $2
`,
[p, pageSize, l]
);
} else {
result = await client.query(
`
[p, pageSize, l]
);
} else {
result = await client.query(
`
SELECT id, name, author_id, author, language, calls, responses, public, version, ts_rank_cd(deck_search , query) AS rank
FROM manydecks.summaries, to_tsquery($4) query
WHERE summaries.public AND ($3::text IS NULL OR summaries.language = $3::text)
@@ -327,49 +339,49 @@ export class Store {
ORDER BY rank DESC
OFFSET $1::int * $2::int LIMIT $2
`,
[p, pageSize, l, query]
);
}
return Store.summariesFromRows(result);
});
[p, pageSize, l, query]
);
}
return Store.summariesFromRows(result);
});
public updateDeck: (
id: number,
user: string,
patch: Patch.Operation[]
) => Promise<Deck.Deck> = async (id, user, patch) =>
await this.withClient(async (client) => {
const deck = await this.getDeck(id);
try {
JsonPatch.applyPatch(deck, patch);
} catch (error) {
if (
error instanceof JsonPatch.JsonPatchError &&
error.name === "TEST_OPERATION_FAILED"
) {
throw new Errors.PatchTestFailed();
} else {
throw new Errors.BadPatch();
}
}
let updated;
try {
updated = Deck.validate({
...deck,
author: undefined,
version: undefined,
});
} catch (error) {
await this.withClient(async (client) => {
const deck = await this.getDeck(id);
try {
JsonPatch.applyPatch(deck, patch);
} catch (error) {
if (
error instanceof JsonPatch.JsonPatchError &&
error.name === "TEST_OPERATION_FAILED"
) {
throw new Errors.PatchTestFailed();
} else {
throw new Errors.BadPatch();
}
await client.query(
`
}
let updated;
try {
updated = Deck.validate({
...deck,
author: undefined,
version: undefined,
});
} catch (error) {
throw new Errors.BadPatch();
}
await client.query(
`
UPDATE manydecks.decks SET deck = $1 WHERE id = $2;
`,
[updated, id]
);
return deck;
});
[updated, id]
);
return deck;
});
public deleteDeck: (id: number, user: string) => Promise<void> = async (
id,
+3 -2
View File
@@ -1,5 +1,5 @@
{
"include": ["./src/ts/**/*"],
"include": ["./src/ts/**/*", "./types/**/*"],
"compilerOptions": {
"target": "esnext",
"module": "esnext",
@@ -19,6 +19,7 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"typeRoots": ["./types"]
}
}
+18
View File
@@ -0,0 +1,18 @@
// This is just a hack-y type def to stop compiler complaints, obviously not complete.
declare namespace Express {
export interface Response {
zip(options: {
filename: string;
files: { name: string; content: string }[];
}): this;
}
}
declare module "express-easy-zip" {
import type { RequestHandler } from "express";
export const Zip: () => RequestHandler;
export default Zip;
}