Merge branch 'dev'

This commit is contained in:
lingdocs 2021-08-26 17:06:57 +04:00
commit 5b7006b2d3
56 changed files with 3680 additions and 4375 deletions

View File

@ -29,5 +29,5 @@ jobs:
cd ..
cd functions
npm install
- name: deploy functions
- name: deploy functions and hosting routes
run: firebase deploy -f --token ${FIREBASE_TOKEN}

View File

@ -3,7 +3,7 @@ name: Functions CI
on:
push:
branches:
- '*'
- master
pull_request:
- '*'
paths:

View File

@ -138,12 +138,72 @@ pm2 start ecosystem.config.js
pm2 save
```
Put behind a NGINX reverse proxy with this config (encryption by LetsEncrypt)
```
server {
server_name account.lingdocs.com;
location / {
proxy_pass http://localhost:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Access-Control-Allow-Origin *;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
error_page 500 /500.json;
location /500.json {
return 500 '{"ok":false,"error":"500 Internal Server Error"}';
}
error_page 502 /502.json;
location /502.json {
return 502 '{"ok":false,"error":"502 Bad Gateway"}';
}
error_page 503 /503.json;
location /503.json {
return 503 '{"ok":false,"error":"503 Service Temporarily Unavailable"}';
}
error_page 504 /504.json;
location /504.json {
return 504 '{"ok":false,"error":"504 Gateway Timeout"}';
}
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/account.lingdocs.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/account.lingdocs.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = account.lingdocs.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name account.lingdocs.com;
listen 80;
listen [::]:80;
return 404; # managed by Certbot
}
```
#### CouchDB
When a user upgrades their account level to `student` or `editor`:
1. A doc in the `_users` db is created with their Firebase Authentication info, account level, and a password they can use for syncing their personal wordlistdb
2. A user database is created (by the firebase functions - *not* by the couchdb_peruser) which they use to sync their personal wordlist.
2. A user database is created (automatically by `couchdb_peruser`) which they use to sync their personal wordlist.
There is also a `review-tasks` database which is used to store all the review tasks for editors and syncs with the review tasks in the app for the editor(s).

View File

@ -1,11 +0,0 @@
name: CI
on:
push:
branches: [ master ]
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v2
- run: npm install
- run: pm2 restart "auth.lingdocs.com"

View File

@ -1,4 +0,0 @@
# auth.lingdocs.com
Auth service for LingDocs (in progress, not usable yet)

View File

@ -2,25 +2,27 @@ import Nano from "nano";
import { DocumentInsertResponse } from "nano";
import { getTimestamp } from "./time-utils";
import env from "./env-vars";
import * as T from "../../../website/src/lib/account-types";
const nano = Nano(env.couchDbURL);
const usersDb = nano.db.use("test-users");
const usersDb = nano.db.use("lingdocs-users");
const userDbPrefix = "userdb-";
export function updateLastActive(user: LingdocsUser): LingdocsUser {
export function updateLastActive(user: T.LingdocsUser): T.LingdocsUser {
return {
...user,
lastActive: getTimestamp(),
};
}
export function updateLastLogin(user: LingdocsUser): LingdocsUser {
export function updateLastLogin(user: T.LingdocsUser): T.LingdocsUser {
return {
...user,
lastLogin: getTimestamp(),
};
}
function processAPIResponse(user: LingdocsUser, response: DocumentInsertResponse): LingdocsUser | undefined {
function processAPIResponse(user: T.LingdocsUser, response: DocumentInsertResponse): T.LingdocsUser | undefined {
if (response.ok !== true) return undefined;
return {
...user,
@ -29,7 +31,7 @@ function processAPIResponse(user: LingdocsUser, response: DocumentInsertResponse
};
}
export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "googleId" | "twitterId", value: string): Promise<undefined | LingdocsUser> {
export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "googleId" | "twitterId", value: string): Promise<undefined | T.LingdocsUser> {
const user = await usersDb.find({
selector: field === "githubId"
? { github: { id: value }}
@ -42,10 +44,15 @@ export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "
if (!user.docs.length) {
return undefined;
}
return user.docs[0] as LingdocsUser;
return user.docs[0] as T.LingdocsUser;
}
export async function insertLingdocsUser(user: LingdocsUser): Promise<LingdocsUser> {
export async function getAllLingdocsUsers(): Promise<T.LingdocsUser[]> {
const users = await usersDb.find({ selector: { userId: { $exists: true }}});
return users.docs as T.LingdocsUser[];
}
export async function insertLingdocsUser(user: T.LingdocsUser): Promise<T.LingdocsUser> {
const res = await usersDb.insert(user);
const newUser = processAPIResponse(user, res);
if (!newUser) {
@ -54,34 +61,53 @@ export async function insertLingdocsUser(user: LingdocsUser): Promise<LingdocsUs
return newUser;
}
export async function deleteLingdocsUser(uuid: UUID): Promise<void> {
export async function deleteLingdocsUser(uuid: T.UUID): Promise<void> {
const user = await getLingdocsUser("userId", uuid);
await deleteCouchDbAuthUser(uuid);
if (!user) return;
// TODO: cleanup userdbs etc
// TODO: Better type certainty here... obviously there is an _id and _rev here
await usersDb.destroy(user._id as string, user._rev as string);
}
export async function deleteCouchDbAuthUser(uuid: T.UUID): Promise<void> {
const authUsers = nano.db.use("_users");
const user = await authUsers.find({ selector: { name: uuid }});
if (!user.docs.length) return;
const u = user.docs[0];
await authUsers.destroy(u._id, u._rev);
}
// TODO: TO MAKE THIS SAFER, PASS IN JUST THE UPDATING FIELDS!!
// TODO: take out the updated object - do just an ID, and then use the toUpdate safe thing
export async function updateLingdocsUser(uuid: UUID, toUpdate:
export async function updateLingdocsUser(uuid: T.UUID, toUpdate:
// TODO: OR USE REDUCER??
{ name: string } |
{ name?: string, email: string, emailVerified: Hash } |
{ name?: string, email: string, emailVerified: T.Hash } |
{ email: string, emailVerified: true } |
{ emailVerified: Hash } |
{ emailVerified: T.Hash } |
{ emailVerified: true } |
{ password: Hash } |
{ google: GoogleProfile | undefined } |
{ github: GitHubProfile | undefined } |
{ twitter: TwitterProfile | undefined } |
{ password: T.Hash } |
{ google: T.GoogleProfile | undefined } |
{ github: T.GitHubProfile | undefined } |
{ twitter: T.TwitterProfile | undefined } |
{
passwordReset: {
tokenHash: Hash,
requestedOn: TimeStamp,
tokenHash: T.Hash,
requestedOn: T.TimeStamp,
},
}
): Promise<LingdocsUser> {
} |
{
level: "student",
wordlistDbName: T.WordlistDbName,
couchDbPassword: T.UserDbPassword,
upgradeToStudentRequest: undefined,
} |
{ userTextOptionsRecord: T.UserTextOptionsRecord } |
{ upgradeToStudentRequest: "waiting" } |
{ upgradeToStudentRequest: "denied" } |
{ lastActive: T.TimeStamp }
): Promise<T.LingdocsUser> {
const user = await getLingdocsUser("userId", uuid);
if (!user) throw new Error("unable to update - user not found " + uuid);
if ("password" in toUpdate) {
@ -96,3 +122,74 @@ export async function updateLingdocsUser(uuid: UUID, toUpdate:
...toUpdate,
});
}
export async function addCouchDbAuthUser(uuid: T.UUID): Promise<{ password: T.UserDbPassword, userDbName: T.WordlistDbName }> {
const password = generateWordlistDbPassword();
const userDbName = getWordlistDbName(uuid);
const usersDb = nano.db.use("_users");
// TODO: prevent conflict if adding an existing user for some reason
const authUser: T.CouchDbAuthUser = {
_id: `org.couchdb.user:${uuid}`,
type: "user",
roles: [],
name: uuid,
password,
};
await usersDb.insert(authUser);
return { password, userDbName };
}
// Instead of these functions, I'm using couch_peruser
// export async function createWordlistDatabase(uuid: T.UUID, password: T.UserDbPassword): Promise<{ name: T.WordlistDbName, password: T.UserDbPassword }> {
// const name = getWordlistDbName(uuid);
// // create wordlist database for user
// await nano.db.create(name);
// const securityInfo = {
// admins: {
// names: [uuid],
// roles: ["_admin"]
// },
// members: {
// names: [uuid],
// roles: ["_admin"],
// },
// };
// const userDb = nano.db.use(name);
// await userDb.insert(securityInfo as any, "_security");
// return { password, name };
// }
// export async function deleteWordlistDatabase(uuid: T.UUID): Promise<void> {
// const name = getWordlistDbName(uuid);
// try {
// await nano.db.destroy(name);
// } catch (e) {
// // allow the error to pass if we're just trying to delete a database that never existed
// if (e.message !== "Database does not exist.") {
// throw new Error("error deleting database");
// }
// }
// }
function generateWordlistDbPassword(): T.UserDbPassword {
function makeChunk(): string {
return Math.random().toString(36).slice(2)
}
const password = new Array(4).fill(0).reduce((acc: string): string => (
acc + makeChunk()
), "");
return password as T.UserDbPassword;
}
function stringToHex(str: string) {
const arr1 = [];
for (let n = 0, l = str.length; n < l; n ++) {
const hex = Number(str.charCodeAt(n)).toString(16);
arr1.push(hex);
}
return arr1.join('');
}
export function getWordlistDbName(uid: T.UUID): T.WordlistDbName {
return `${userDbPrefix}${stringToHex(uid)}` as T.WordlistDbName;
}

View File

@ -8,6 +8,7 @@ const names = [
"LINGDOCS_ACCOUNT_TWITTER_CLIENT_SECRET",
"LINGDOCS_ACCOUNT_GITHUB_CLIENT_SECRET",
"LINGDOCS_ACCOUNT_RECAPTCHA_SECRET",
"LINGDOCS_ACCOUNT_UPGRADE_PASSWORD",
];
const values = names.map((name) => ({
@ -31,4 +32,5 @@ export default {
twitterClientSecret: values[6].value,
githubClientSecret: values[7].value,
recaptchaSecret: values[8].value,
upgradePassword: values[9].value,
};

View File

@ -1,15 +1,16 @@
import nodemailer from "nodemailer";
import inProd from "./inProd";
import env from "./env-vars";
import * as T from "../../../website/src/lib/account-types";
type Address = string | { name: string, address: string };
const from: Address = {
const adminAddress: Address = {
name: "LingDocs Admin",
address: "admin@lingdocs.com",
};
function getAddress(user: LingdocsUser): Address {
function getAddress(user: T.LingdocsUser): Address {
// TODO: Guard against ""
if (!user.name) return user.email || "";
return {
@ -30,31 +31,52 @@ const transporter = nodemailer.createTransport({
async function sendEmail(to: Address, subject: string, text: string) {
await transporter.sendMail({
from,
from: adminAddress,
to,
subject,
text,
});
}
// TODO: MAKE THIS
// TODO: MAKE THIS A URL ACROSS PROJECT
const baseURL = inProd ? "https://account.lingdocs.com" : "http://localhost:4000";
export async function sendVerificationEmail(user: LingdocsUser, token: URLToken) {
export async function sendVerificationEmail(user: T.LingdocsUser, token: T.URLToken) {
const subject = "Please Verify Your E-mail";
const content = `Hello ${user.name},
Please verify your email by visiting this link: ${baseURL}/email-verification/${user.userId}/${token}
LingDocs Admin`;
await sendEmail(getAddress(user), "Please Verify Your E-mail", content);
await sendEmail(getAddress(user), subject, content);
}
export async function sendPasswordResetEmail(user: LingdocsUser, token: URLToken) {
export async function sendPasswordResetEmail(user: T.LingdocsUser, token: T.URLToken) {
const subject = "Reset Your Password";
const content = `Hello ${user.name},
Please visit this link to reset your password: ${baseURL}/password-reset/${user.userId}/${token}
LingDocs Admin`;
await sendEmail(getAddress(user), "Reset Your Password", content);
await sendEmail(getAddress(user), subject, content);
}
export async function sendAccountUpgradeMessage(user: T.LingdocsUser) {
const subject = "You're Upgraded to Student";
const content = `Hello ${user.name},
Congratulations on your upgrade to a LingDocs Student account! 👨🎓
Now you can start using your wordlist in the dictionary. It will automatically sync across any devices you're signed in to.
LingDocs Admin`;
await sendEmail(getAddress(user), subject, content);
}
export async function sendUpgradeRequestToAdmin(userWantingToUpgrade: T.LingdocsUser) {
const subject = "Account Upgrade Request";
const content = `${userWantingToUpgrade.name} - ${userWantingToUpgrade.email} - ${userWantingToUpgrade.userId} is requesting to upgrade to student.`;
await sendEmail(adminAddress, subject, content);
}

View File

@ -1,23 +1,24 @@
import { hash, compare } from "bcryptjs";
import { randomBytes } from "crypto";
import base64url from "base64url";
import * as T from "../../../website/src/lib/account-types";
const tokenSize = 24;
export async function getHash(p: string): Promise<Hash> {
return await hash(p, 10) as Hash;
export async function getHash(p: string): Promise<T.Hash> {
return await hash(p, 10) as T.Hash;
}
export async function getEmailTokenAndHash(): Promise<{ token: URLToken, hash: Hash }> {
export async function getEmailTokenAndHash(): Promise<{ token: T.URLToken, hash: T.Hash }> {
const token = getURLToken();
const h = await getHash(token);
return { token, hash: h };
}
export function getURLToken(): URLToken {
return base64url(randomBytes(tokenSize)) as URLToken;
export function getURLToken(): T.URLToken {
return base64url(randomBytes(tokenSize)) as T.URLToken;
}
export function compareToHash(s: string, hash: Hash): Promise<boolean> {
export function compareToHash(s: string, hash: T.Hash): Promise<boolean> {
return compare(s, hash);
}

View File

@ -1,3 +1,5 @@
export function getTimestamp(): TimeStamp {
return Date.now() as TimeStamp;
import * as T from "../../../website/src/lib/account-types";
export function getTimestamp(): T.TimeStamp {
return Date.now() as T.TimeStamp;
}

View File

@ -1,18 +1,26 @@
import { v4 as uuidv4 } from "uuid";
import { insertLingdocsUser } from "../lib/couch-db";
import {
insertLingdocsUser,
addCouchDbAuthUser,
updateLingdocsUser,
} from "../lib/couch-db";
import {
getHash,
getEmailTokenAndHash,
} from "../lib/password-utils";
import { getTimestamp } from "../lib/time-utils";
import { sendVerificationEmail } from "../lib/mail-utils";
import {
sendVerificationEmail,
sendAccountUpgradeMessage,
} from "../lib/mail-utils";
import { outsideProviders } from "../middleware/setup-passport";
import * as T from "../../../website/src/lib/account-types";
function getUUID(): UUID {
return uuidv4() as UUID;
function getUUID(): T.UUID {
return uuidv4() as T.UUID;
}
export function canRemoveOneOutsideProvider(user: LingdocsUser): boolean {
export function canRemoveOneOutsideProvider(user: T.LingdocsUser): boolean {
if (user.email && user.password) {
return true;
}
@ -20,7 +28,7 @@ export function canRemoveOneOutsideProvider(user: LingdocsUser): boolean {
return providersPresent.length > 1;
}
export function getVerifiedEmail({ emails }: ProviderProfile): string | false {
export function getVerifiedEmail({ emails }: T.ProviderProfile): string | false {
return (
emails
&& emails.length
@ -29,7 +37,7 @@ export function getVerifiedEmail({ emails }: ProviderProfile): string | false {
) ? emails[0].value : false;
}
function getEmailFromGoogleProfile(profile: GoogleProfile): { email: string | undefined, verified: boolean } {
function getEmailFromGoogleProfile(profile: T.GoogleProfile): { email: string | undefined, verified: boolean } {
if (!profile.emails || profile.emails.length === 0) {
return { email: undefined, verified: false };
}
@ -42,6 +50,33 @@ function getEmailFromGoogleProfile(profile: GoogleProfile): { email: string | un
};
}
export async function upgradeUser(userId: T.UUID): Promise<T.UpgradeUserResponse> {
// add user to couchdb authentication db
const { password, userDbName } = await addCouchDbAuthUser(userId);
// // create user db
// update LingdocsUser
const user = await updateLingdocsUser(userId, {
level: "student",
wordlistDbName: userDbName,
couchDbPassword: password,
upgradeToStudentRequest: undefined,
});
if (user.email) {
sendAccountUpgradeMessage(user).catch(console.error);
}
return {
ok: true,
message: "user upgraded to student",
user,
};
}
export async function denyUserUpgradeRequest(userId: T.UUID): Promise<void> {
await updateLingdocsUser(userId, {
upgradeToStudentRequest: "denied",
});
}
export async function createNewUser(input: {
strategy: "local",
email: string,
@ -49,20 +84,20 @@ export async function createNewUser(input: {
passwordPlainText: string,
} | {
strategy: "github",
profile: GitHubProfile,
profile: T.GitHubProfile,
} | {
strategy: "google",
profile: GoogleProfile,
profile: T.GoogleProfile,
} | {
strategy: "twitter",
profile: TwitterProfile,
}): Promise<LingdocsUser> {
profile: T.TwitterProfile,
}): Promise<T.LingdocsUser> {
const userId = getUUID();
const now = getTimestamp();
if (input.strategy === "local") {
const email = await getEmailTokenAndHash();
const password = await getHash(input.passwordPlainText);
const newUser: LingdocsUser = {
const newUser: T.LingdocsUser = {
_id: userId,
userId,
email: input.email,
@ -73,6 +108,7 @@ export async function createNewUser(input: {
tests: [],
lastLogin: now,
lastActive: now,
userTextOptionsRecord: undefined,
};
const user = await insertLingdocsUser(newUser);
sendVerificationEmail(user, email.token).catch(console.error);
@ -80,7 +116,7 @@ export async function createNewUser(input: {
}
// GitHub || Twitter
if (input.strategy === "github" || input.strategy === "twitter") {
const newUser: LingdocsUser = {
const newUser: T.LingdocsUser = {
_id: userId,
userId,
emailVerified: false,
@ -90,6 +126,7 @@ export async function createNewUser(input: {
tests: [],
lastLogin: now,
lastActive: now,
userTextOptionsRecord: undefined,
};
const user = await insertLingdocsUser(newUser);
return user;
@ -99,7 +136,7 @@ export async function createNewUser(input: {
const { email, verified } = getEmailFromGoogleProfile(input.profile);
if (email && !verified) {
const em = await getEmailTokenAndHash();
const newUser: LingdocsUser = {
const newUser: T.LingdocsUser = {
_id: userId,
userId,
email,
@ -110,12 +147,13 @@ export async function createNewUser(input: {
tests: [],
lastActive: now,
level: "basic",
userTextOptionsRecord: undefined,
}
const user = await insertLingdocsUser(newUser);
sendVerificationEmail(user, em.token);
return user;
}
const newUser: LingdocsUser = {
const newUser: T.LingdocsUser = {
_id: userId,
userId,
email,
@ -126,7 +164,8 @@ export async function createNewUser(input: {
tests: [],
lastActive: now,
level: "basic",
userTextOptionsRecord: undefined,
}
const user = await insertLingdocsUser(newUser);
return user;
}
}

View File

@ -16,6 +16,8 @@ import {
getVerifiedEmail,
} from "../lib/user-utils";
import env from "../lib/env-vars";
import * as T from "../../../website/src/lib/account-types";
import { getTimestamp } from "../lib/time-utils";
export const outsideProviders: ("github" | "google" | "twitter")[] = ["github", "google", "twitter"];
@ -116,7 +118,7 @@ function setupPassport(passport: PassportStatic) {
async function(req: any, accessToken: any, refreshToken: any, profileRaw: any, done: any) {
// not getting refresh token
const { _json, _raw, ...profile } = profileRaw;
const ghProfile: GitHubProfile = { ...profile, accessToken };
const ghProfile: T.GitHubProfile = { ...profile, accessToken };
try {
if (req.isAuthenticated()) {
if (!req.user) done(new Error("user lost"));
@ -142,14 +144,14 @@ function setupPassport(passport: PassportStatic) {
cb(null, user.userId);
});
passport.deserializeUser(async (userId: UUID, cb) => {
passport.deserializeUser(async (userId: T.UUID, cb) => {
try {
const user = await getLingdocsUser("userId", userId);
if (!user) {
cb(null, false);
return;
}
const newUser = await insertLingdocsUser(updateLastActive(user));
const newUser = await updateLingdocsUser(userId, { lastActive: getTimestamp() });
cb(null, newUser);
} catch (err) {
cb(err, null);

View File

@ -20,8 +20,7 @@ function setupSession(app: Express) {
maxAge: 1000 * 60 * 60 * 24 * 7 * 30 * 6,
secure: inProd,
domain: inProd ? "lingdocs.com" : undefined,
// TODO: TRY TO SET TO TRUE
httpOnly: false,
httpOnly: true,
},
store: inProd
? new RedisStore({ client: redis.createClient() })

View File

@ -1,19 +1,28 @@
import express, { Response } from "express";
import {
deleteLingdocsUser,
getLingdocsUser,
updateLingdocsUser,
deleteCouchDbAuthUser,
} from "../lib/couch-db";
import {
getHash,
getURLToken,
compareToHash,
getEmailTokenAndHash,
} from "../lib/password-utils";
import {
sendUpgradeRequestToAdmin,
sendVerificationEmail,
} from "../lib/mail-utils";
import {
upgradeUser,
} from "../lib/user-utils";
import * as T from "../../../website/src/lib/account-types";
import env from "../lib/env-vars";
function sendResponse(res: Response, payload: APIResponse) {
// TODO: ADD PROPER ERROR HANDLING THAT WILL RETURN JSON ALWAYS
function sendResponse(res: Response, payload: T.APIResponse) {
return res.send(payload);
}
@ -24,7 +33,7 @@ apiRouter.use((req, res, next) => {
if (req.isAuthenticated()) {
return next();
}
const r: APIResponse = { ok: false, error: "401 Unauthorized" };
const r: T.APIResponse = { ok: false, error: "401 Unauthorized" };
return res.status(401).send(r);
});
@ -87,6 +96,65 @@ apiRouter.put("/email-verification", async (req, res, next) => {
}
});
apiRouter.put("/user/userTextOptionsRecord", async (req, res, next) => {
if (!req.user) throw new Error("user not found");
try {
const { userTextOptionsRecord } = req.body as T.UpdateUserTextOptionsRecordBody;
const user = await updateLingdocsUser(req.user.userId, { userTextOptionsRecord });
const toSend: T.UpdateUserTextOptionsRecordResponse = { ok: true, message: "updated userTextOptionsRecord", user };
res.send(toSend);
} catch (e) {
next(e);
}
});
apiRouter.put("/user/upgrade", async (req, res, next) => {
if (!req.user) throw new Error("user not found");
try {
const givenPassword = (req.body.password || "") as string;
const studentPassword = env.upgradePassword;
if (givenPassword.toLowerCase().trim() !== studentPassword.toLowerCase()) {
const wrongPass: T.UpgradeUserResponse = {
ok: false,
error: "incorrect password",
};
res.send(wrongPass);
return;
}
const { userId } = req.user;
const user = await getLingdocsUser("userId", userId);
if (!user) throw new Error("user lost");
if (user.level !== "basic") {
const alreadyUpgraded: T.UpgradeUserResponse = {
ok: true,
message: "user already upgraded",
user,
};
res.send(alreadyUpgraded);
return;
}
const upgraded: T.UpgradeUserResponse = await upgradeUser(userId);
res.send(upgraded);
} catch (e) {
next(e);
}
});
apiRouter.post("/user/upgradeToStudentRequest", async (req, res, next) => {
if (!req.user) throw new Error("user not found");
try {
if (req.user.level === "student" || req.user.level === "editor") {
res.send({ ok: true, message: "user already upgraded" });
return;
}
sendUpgradeRequestToAdmin(req.user).catch(console.error);
await updateLingdocsUser(req.user.userId, { upgradeToStudentRequest: "waiting" });
res.send({ ok: true, message: "request for upgrade sent" });
} catch (e) {
next(e);
}
});
/**
* deletes a users own account
*/
@ -94,7 +162,7 @@ apiRouter.delete("/user", async (req, res, next) => {
try {
if (!req.user) throw new Error("user not found");
await deleteLingdocsUser(req.user.userId);
sendResponse(res, { ok: true, message: "user delted" });
sendResponse(res, { ok: true, message: "user deleted" });
} catch (e) {
next(e);
}
@ -103,7 +171,7 @@ apiRouter.delete("/user", async (req, res, next) => {
/**
* signs out the user signed in
*/
apiRouter.post("/sign-out" , (req, res) => {
apiRouter.post("/sign-out", (req, res) => {
req.logOut();
sendResponse(res, { ok: true, message: "signed out" });
});

View File

@ -1,6 +1,8 @@
import { Router } from "express";
import { PassportStatic } from "passport";
import {
deleteLingdocsUser,
getAllLingdocsUsers,
getLingdocsUser,
updateLingdocsUser,
} from "../lib/couch-db";
@ -11,6 +13,10 @@ import {
compareToHash,
getEmailTokenAndHash,
} from "../lib/password-utils";
import {
upgradeUser,
denyUserUpgradeRequest,
} from "../lib/user-utils";
import { validateReCaptcha } from "../lib/recaptcha";
import {
getTimestamp,
@ -21,6 +27,7 @@ import {
} from "../lib/mail-utils";
import { outsideProviders } from "../middleware/setup-passport";
import inProd from "../lib/inProd";
import * as T from "../../../website/src/lib/account-types";
const authRouter = (passport: PassportStatic) => {
const router = Router();
@ -71,7 +78,7 @@ const authRouter = (passport: PassportStatic) => {
return res.render("login", { recaptcha: "fail", inProd });
}
}
passport.authenticate("local", (err, user: LingdocsUser | undefined, info) => {
passport.authenticate("local", (err, user: T.LingdocsUser | undefined, info) => {
if (err) throw err;
if (!user && info.message === "email not found") {
return res.send({ ok: false, newSignup: true });
@ -128,17 +135,64 @@ const authRouter = (passport: PassportStatic) => {
try {
const { email, password, name } = req.body;
const existingUser = await getLingdocsUser("email", email);
if (existingUser) return res.send("Tser Already Exists");
if (existingUser) return res.send("User Already Exists");
const user = await createNewUser({ strategy: "local", email, passwordPlainText: password, name });
req.logIn(user, (err) => {
if (err) return next(err);
return res.send({ ok: true, user });
});
} catch(e) {
return next(e);
next(e);
}
});
router.get("/admin", async (req, res, next) => {
try {
if (!req.user || !req.user.admin) {
return res.redirect("/");
}
const users = await getAllLingdocsUsers();
res.render("admin", { users });
} catch (e) {
next(e);
}
});
/**
* Grant request for upgrade to student
*/
router.post("/admin/upgradeToStudent/:userId/:grantOrDeny", async (req, res, next) => {
try {
if (!req.user || !req.user.admin) {
return res.redirect("/");
}
const userId = req.params.userId as T.UUID;
const grantOrDeny = req.params.grantOrDeny as "grant" | "deny";
if (grantOrDeny === "grant") {
await upgradeUser(userId);
} else {
await denyUserUpgradeRequest(userId);
}
res.redirect("/admin");
} catch (e) {
next(e);
}
});
router.delete("/admin/:userId", async (req, res, next) => {
try {
// TODO: MAKE PROPER MIDDLEWARE WITH TYPING
if (!req.user || !req.user.admin) {
return res.redirect("/");
}
const toDelete = req.params.userId as T.UUID;
await deleteLingdocsUser(toDelete);
res.send({ ok: true, message: "user deleted" });
} catch (e) {
next(e);
}
});
router.get("/email-verification/:uuid/:token", async (req, res, next) => {
const page = "email-verification";
const { uuid, token } = req.params;

View File

@ -1,37 +0,0 @@
type Hash = string & { __brand: "Hashed String" };
type UUID = string & { __brand: "Random Unique UID" };
type TimeStamp = number & { __brand: "UNIX Timestamp in milliseconds" };
type UserDbPassword = string & { __brand: "password for an individual user couchdb" };
type URLToken = string & { __brand: "Base 64 URL Token" };
type EmailVerified = true | Hash | false;
type ActionComplete = { ok: true, message: string };
type ActionError = { ok: false, error: string };
type APIResponse = ActionComplete | ActionError | { ok: true, user: LingdocsUser };
type WoutRJ<T> = Omit<T, "_raw"|"_json">;
type GoogleProfile = WoutRJ<import("passport-google-oauth").Profile> & { refreshToken: string, accessToken: string };
type GitHubProfile = WoutRJ<import("passport-github2").Profile> & { accessToken: string };
type TwitterProfile = WoutRJ<import("passport-twitter").Profile> & { token: string, tokenSecret: string };
type ProviderProfile = GoogleProfile | GitHubProfile | TwitterProfile;
type UserLevel = "basic" | "student" | "editor";
// TODO: TYPE GUARDING SO WE NEVER HAVE A USER WITH NO Id or Password
type LingdocsUser = {
userId: UUID,
password?: Hash,
name: string,
email?: string,
emailVerified: EmailVerified,
github?: GitHubProfile,
google?: GoogleProfile,
twitter?: TwitterProfile,
passwordReset?: {
tokenHash: Hash,
requestedOn: TimeStamp,
},
tests: [],
lastLogin: TimeStamp,
lastActive: TimeStamp,
} & ({ level: "basic"} | { level: "student" | "editor", userDbPassword: UserDbPassword })
& import("nano").MaybeDocument;

95
account/views/admin.ejs Normal file
View File

@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LingDocs Signin">
<title>Admin · LingDocs</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous">
</head>
<script>
function handleDeleteUser(uid, name) {
const answer = confirm(`Are you sure you want to delete ${name}?`);
if (answer) {
fetch(`/admin/${uid}`, {
method: "DELETE",
}).then((res) => res.json()).then((res) => {
console.log(res);
if (res.ok) {
window.location = "/admin";
}
}).catch(console.error);
}
}
</script>
<body>
<div class="container">
<h1 class="my-4">LingDocs Auth Admin</h1>
<table class="table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Providers</th>
<th scope="col">Level</th>
<th scope="col">Last Active</th>
<th shope="col"></th>
</tr>
</thead>
<tbody>
<% for(var i=0; i < users.length; i++) { %>
<tr>
<td><%= users[i].name %> <% if (users[i].admin) { %>
<i class="fas fa-id-badge ml-2"></i>
<% } %>
</td>
<td><%= users[i].email %></td>
<td>
<% if (users[i].password && users[i].email) { %>
<i class="fas fa-key mr-2"></i>
<% } %>
<% if (users[i].google) { %>
<i class="fab fa-google mr-2"></i>
<% } %>
<% if (users[i].twitter) { %>
<i class="fab fa-twitter mr-2"></i>
<% } %>
<% if (users[i].github) { %>
<i class="fab fa-github mr-2"></i>
<% } %>
</td>
<td>
<% if (users[i].upgradeToStudentRequest === "waiting") { %>
<div class="d-flex flex-row">
<div>Requested Upgrade </div>
<div>
<form action="/admin/upgradeToStudent/<%= users[i].userId %>/grant" method="POST">
<button class="btn btn-sm btn-success mx-2" type="submit"><i class="fas fa-thumbs-up mr-2"></i> Grant </button>
</form>
</div>
<div>
<form action="/admin/upgradeToStudent/<%= users[i].userId %>/deny" method="POST">
<button class="btn btn-sm btn-danger" type="submit"><i class="fas fa-thumbs-down mr-2"></i> Deny </button>
</form>
</div>
</div>
<% } else if (users[i].upgradeToStudentRequest === "waiting"){ %>
Upgrade Denied
<% } else { %>
<%= users[i].level %>
<% } %>
</td>
<td>
<%= new Date(users[i].lastActive).toUTCString() %>
</td>
<td>
<button class="btn btn-sm btn-danger" onClick="handleDeleteUser('<%= users[i].userId %>', '<%= users[i].name %>')"><i class="fa fa-trash"></i></button>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</body>
</html>

View File

@ -7,11 +7,17 @@
<title>Account · LingDocs</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous">
<script>
</script>
</head>
<body>
<div class="container" style="max-width: 400px;">
<h2 class="mt-4 mb-4 text-center">LingDocs Account</h2>
<h4>Profile:</h4>
<% if (user.admin) { %>
<a href="/admin"><h5 class="mb-2">Admin Console</h5></a>
<% } %>
<h4>Profile <i class="fas fa-user ml-2"></i></h4>
<form method="POST" class="mb-4">
<div>
<label for="email" class="form-label">Email:</label>
@ -44,56 +50,67 @@
<div>
<button type="submit" class="btn btn-primary">Update Profile</button>
</div>
<% if (user.email) { %>
<h4 class="mt-3">Password:</h4>
<% } %>
<div id="password-change-form" style="display: none;">
<% if (user.password) { %>
<div id="old-password-form">
<% } else { %>
<div id="old-password-form" style="display: none;">
<% } %>
<div class="mb-3 mt-3">
<label for="oldPassword" class="form-label">Old Password:</label>
<input type="password" class="form-control" id="oldPassword">
</div>
<div class="small text-left" id="forgot-password">
<a href="" tabindex="-1">Forgot Old Password?</a>
</div>
</div>
<div class="mb-3 mt-3">
<label for="password" class="form-label">New Password:</label>
<input type="password" class="form-control" id="password" />
</div>
<div class="mb-4 mt-3">
<label for="confirmPassword" class="form-label">Confirm New Password:</label>
<input type="password" class="form-control" id="confirmPassword">
</div>
</div>
<div id="password-change-result" style="display: none;" class="alert alert-info mt-3 mb-4" role="alert">
</div>
<% if (user.email) { %>
<div class="d-flex flex-row justify-content-between mt-4 mb-3">
<button type="button" id="password-change-button" class="btn btn-secondary">
<% if (user.password) { %>
Change
<% } else { %>
Add
<% } %>
Password
</button>
<button type="button" style="display: none;" id="cancel-password-change-button" class="btn btn-light">Cancel</button>
</div>
<% } %>
</form>
<h4 class="mb-2">Linked Accounts:</h4>
<h5>Account Level: <%= user.level.charAt(0).toUpperCase() + user.level.slice(1) %></h5>
<% if (user.level === "basic") { %>
<% if (user.upgradeToStudentRequest === "waiting") { %>
<p>Wating for upgrade approval</p>
<% } else { %>
<button class="btn btn-sm btn-secondary" id="upgrade-request-button" onclick="handleRequestUpgrade()">Request Upgrade</button>
<% } %>
<% } %>
<% if (user.email) { %>
<h4 class="mt-3 mb-3">Password <i class="fas fa-key ml-2"></i></h4>
<% if (!user.password) { %>
<p class="small">Add a password to be able to log in with just your e-mail address.</p>
<% } %>
<% } %>
<div id="password-change-form" style="display: none;">
<% if (user.password) { %>
<div id="old-password-form">
<% } else { %>
<div id="old-password-form" style="display: none;">
<% } %>
<div class="mb-3 mt-3">
<label for="oldPassword" class="form-label">Old Password:</label>
<input type="password" class="form-control" id="oldPassword">
</div>
<div class="small text-left" id="forgot-password">
<a href="" tabindex="-1">Forgot Old Password?</a>
</div>
</div>
<div class="mb-3 mt-3">
<label for="password" class="form-label">New Password:</label>
<input type="password" class="form-control" id="password" />
</div>
<div class="mb-4 mt-3">
<label for="confirmPassword" class="form-label">Confirm New Password:</label>
<input type="password" class="form-control" id="confirmPassword">
</div>
</div>
<div id="password-change-result" style="display: none;" class="alert alert-info mt-3 mb-4" role="alert">
</div>
<% if (user.email) { %>
<div class="d-flex flex-row justify-content-between mt-2 mb-1">
<button type="button" id="password-change-button" class="btn btn-secondary">
<% if (user.password) { %>
Change
<% } else { %>
Add
<% } %>
Password
</button>
<button type="button" style="display: none;" id="cancel-password-change-button" class="btn btn-light">Cancel</button>
</div>
<% } %>
<h4 class="mt-3 mb-1">Linked Accounts <i class="fas fa-link ml-2"></i></h4>
<div class="mb-4">
<% if (user.google) { %>
<!-- TODO: MAKE THIS EMAIL THING SAFER! -->
<div class="my-2 w-100 btn btn-secondary"><i class="fab fa-google mr-2"></i> Linked to Google · <%= user.google.emails[0].value %></div>
<form action="/google/remove" method="POST">
<% if (removeProviderOption) { %>
<button type="submit" class="btn btn-sm">Unlink from Google</button>
<button type="submit" class="btn btn-sm btn-outline">Unlink from Google</button>
<% } %>
</form>
<% } %>
@ -101,7 +118,7 @@
<div class="my-2 w-100 btn btn-secondary"><i class="fab fa-twitter mr-2"></i> Linked to Twitter · @<%= user.twitter.username %></div>
<form action="/twitter/remove" method="POST">
<% if (removeProviderOption) { %>
<button type="submit" class="btn btn-sm">Unlink from twitter</button>
<button type="submit" class="btn btn-sm btn-outline">Unlink from Twitter</button>
<% } %>
</form>
<% } %>
@ -109,7 +126,7 @@
<div class="my-2 w-100 btn btn-secondary"><i class="fab fa-github mr-2"></i> Linked to GitHub · <%= user.github.username %></div>
<form action="/github/remove" method="POST">
<% if (removeProviderOption) { %>
<button type="submit" class="btn btn-sm">Unlink from GitHub</button>
<button type="submit" class="btn btn-sm btn-outline">Unlink from GitHub</button>
<% } %>
</form>
<% } %>
@ -138,6 +155,37 @@
</body>
<script>
if (window.opener) {
const w = window.opener
try {
w.postMessage("signed in", "https://dictionary.lingdocs.com");
} catch (e) {
console.error(e);
}
try {
w.postMessage("signed in", "https://dev.dictionary.lingdocs.com");
} catch (e) {
console.error(e);
}
}
function handleRequestUpgrade() {
console.log("got here");
const btn = document.getElementById("upgrade-request-button");
btn.innerHTML = "Sending...";
fetch("/api/user/upgradeToStudentRequest", {
method: "POST",
}).then((res) => res.json()).then((res) => {
console.log(res);
if (res.ok) {
btn.innerHTML = "Upgrade request sent";
} else {
btn.innerHTML = "Error requesting upgrade";
}
}).catch((e) => {
console.error(e);
btn.innerHTML = "Error requesting upgrade";
});
}
function clearPasswordForm() {
document.getElementById("oldPassword").value = "";
document.getElementById("password").value = "";

View File

@ -6,9 +6,13 @@
"hosting": {
"public": "public",
"rewrites": [
{
"source": "/authory",
"function": "authory"
{
"source": "/publishDictionary",
"function": "/publishDictionary"
},
{
"source": "/submissions",
"function": "/submissions"
}
]
}

View File

@ -1,84 +0,0 @@
const nano = require("nano");
const oldCouch = nano(process.env.OLD_WORDLIST_COUCHDB);
const newCouch = nano(process.env.LINGDOCS_COUCHDB);
const email = process.argv[2];
const newEmail = process.argv[3];
function stringToHex(str) {
const arr1 = [];
for (let n = 0, l = str.length; n < l; n ++) {
const hex = Number(str.charCodeAt(n)).toString(16);
arr1.push(hex);
}
return arr1.join('');
}
async function getOldWordList() {
const usersDb = oldCouch.use("_users");
const res = await usersDb.find({
selector: {
originalEmail: email,
},
});
const { name } = res.docs[0];
const tag = stringToHex(name);
const userDb = oldCouch.db.use(`userdb-${tag}`);
const { rows } = await userDb.list({ include_docs: true });
const allDocs = rows.map((row) => row.doc);
return allDocs
}
function convertWordList(list) {
const now = Date.now();
return list.map((item) => ({
_id: item._id,
warmup: "done",
supermemo: {
interval: 0,
repetition: 0,
efactor: 2.5
},
dueDate: now,
entry: { ...item.w },
notes: item.notes,
}));
}
async function uploadToNewDb(wordlist) {
const usersDb = newCouch.use("_users");
const res = await usersDb.find({
selector: {
email: newEmail || email,
},
});
const { name } = res.docs[0];
const tag = stringToHex(name);
const userDb = newCouch.db.use(`userdb-${tag}`);
await userDb.bulk({ docs: wordlist });
}
// async function updateWarmup() {
// const usersDb = newCouch.use("_users");
// const res = await usersDb.find({
// selector: {
// email: newEmail || email,
// },
// });
// const { name } = res.docs[0];
// const tag = stringToHex(name);
// const userDb = newCouch.db.use(`userdb-${tag}`);
// const { rows } = await userDb.list({ include_docs: true });
// const allDocs = rows.map((row) => row.doc);
// const updated = allDocs.map((d) => ({ ...d, warmup: "done" }));
// await userDb.bulk({ docs: updated });
// }
async function main() {
const oldWordList = await getOldWordList();
const newWordList = convertWordList(oldWordList);
uploadToNewDb(newWordList)
}
main();

View File

@ -410,6 +410,16 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.55.tgz",
"integrity": "sha512-koZJ89uLZufDvToeWO5BrC4CR4OUfHnUz2qoPs/daQH6qq3IN62QFxCTZ+bKaCE0xaoCAJYE4AXre8AbghCrhg=="
},
"@types/node-fetch": {
"version": "2.5.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz",
"integrity": "sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==",
"dev": true,
"requires": {
"@types/node": "*",
"form-data": "^3.0.0"
}
},
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
@ -540,6 +550,12 @@
"retry": "0.12.0"
}
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
},
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
@ -648,6 +664,15 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
},
"compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@ -731,6 +756,12 @@
"ms": "2.1.2"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
},
"depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@ -980,6 +1011,17 @@
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz",
"integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA=="
},
"form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"forwarded": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",

View File

@ -22,12 +22,14 @@
"firebase-functions": "^3.11.0",
"google-spreadsheet": "^3.1.15",
"nano": "^9.0.3",
"node-fetch": "^2.6.1",
"react": "^17.0.1",
"react-bootstrap": "^1.5.1",
"react-dom": "^17.0.1"
},
"devDependencies": {
"@types/jest": "^26.0.20",
"@types/node-fetch": "^2.5.12",
"firebase-functions-test": "^0.2.0",
"typescript": "^3.8.0"
},

View File

@ -1,9 +0,0 @@
export default function generatePassword(): string {
function makeChunk(): string {
return Math.random().toString(36).slice(2)
}
const password = new Array(4).fill(0).reduce((acc: string): string => (
acc + makeChunk()
), "");
return password;
}

View File

@ -1,232 +1,46 @@
import * as functions from "firebase-functions";
import * as FT from "../../website/src/lib/functions-types";
import { receiveSubmissions } from "./submissions";
import lingdocsAuth from "./middleware/lingdocs-auth";
import publish from "./publish";
import {
receiveSubmissions,
} from "./submissions";
import generatePassword from "./generate-password";
import * as BT from "../../website/src/lib/backend-types"
import cors from "cors";
import * as admin from "firebase-admin";
import { getUserDbName } from "./lib/userDbName";
const nano = require("nano")(functions.config().couchdb.couchdb_url);
const usersDb = nano.db.use("_users");
admin.initializeApp();
const validateFirebaseIdToken = async (req: any, res: any, next: any) => {
if ((!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) &&
!(req.cookies && req.cookies.__session)) {
res.status(403).send({ message: "Unauthorized" });
return;
}
let idToken;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
// Read the ID Token from the Authorization header.
idToken = req.headers.authorization.split('Bearer ')[1];
} else if(req.cookies) {
// Read the ID Token from cookie.
idToken = req.cookies.__session;
} else {
// No cookie
res.status(403).send({ message: "Unauthorized" });
return;
}
try {
const decodedIdToken = await admin.auth().verifyIdToken(idToken);
req.user = decodedIdToken;
next();
return;
} catch (error) {
console.error('Error while verifying Firebase ID token:', error);
res.status(403).send({ message: "Unauthorized" });
return;
}
};
const isEditor = async (req: any) => {
const uid = req.user.uid as string;
const couchDbUser = await getCouchDbUser(uid);
return !!couchDbUser && couchDbUser.level === "editor";
}
export const publishDictionary = functions
.region("europe-west1")
.runWith({
timeoutSeconds: 200,
memory: "2GB"
})
.https.onRequest((req, res) => {
return cors({ origin: true })(req, res, () => {
validateFirebaseIdToken(req, res, async () => {
try {
const response = await publish();
return res.send(response);
} catch (error) {
return res.status(500).send({
error: error.toString(),
});
}
});
});
});
// TODO: BETTER HANDLING OF EXPRESS MIDDLEWARE
export const submissions = functions
.region("europe-west1")
.runWith({
timeoutSeconds: 30,
memory: "1GB"
})
.https.onRequest((req, res) => {
return cors({ origin: true })(req, res, () => {
validateFirebaseIdToken(req, res, async () => {
if (!Array.isArray(req.body)) {
res.status(400).send({
ok: false,
error: "invalid submission",
});
return;
}
const suggestions = req.body as BT.SubmissionsRequest;
// @ts-ignore
const uid = req.user.uid as string;
const editor = await isEditor(req);
try {
const response = await receiveSubmissions(suggestions, editor);
// TODO: WARN IF ANY OF THE EDITS DIDN'T HAPPEN
res.send(response);
return;
} catch (error) {
console.error(error);
return res.status(500).send({
error: error.toString(),
});
};
}).catch(console.error);
});
});
export const getUserInfo = functions.region("europe-west1").https.onRequest((req, res) => {
return cors({ origin: true })(req, res, () => {
validateFirebaseIdToken(req, res, async () => {
try {
// @ts-ignore
const uid = req.user.uid as string;
const user = await getCouchDbUser(uid);
if (!user) {
const noneFound: BT.GetUserInfoResponse = {
ok: true,
message: "no couchdb user found",
};
res.send(noneFound);
return;
}
const userFound: BT.GetUserInfoResponse = { ok: true, user };
res.send(userFound);
return;
} catch(error) {
console.error(error);
res.status(500).send({
ok: false,
error: error.message,
});
}
}).catch(console.error);
});
});
// export const cleanUpUser = functions
// .region("europe-west1")
// .auth.user().onDelete(async (user) => {
// const couchDbUser = await getCouchDbUser(user.uid);
// if (!couchDbUser) return;
// await usersDb.destroy(
// `org.couchdb.user:${user.uid}`,
// couchDbUser._rev,
// );
// try {
// await nano.db.destroy(getUserDbName(user.uid));
// } catch (e) {
// console.log("errored destroying", e);
// };
// });
export const upgradeUser = functions.region("europe-west1").https.onRequest((req, res) => {
return cors({ origin: true })(req, res, () => {
validateFirebaseIdToken(req, res, async () => {
const password = (req.body.password || "") as string;
const studentPassword = functions.config().upgrades.student_password as string;
if (password.toLowerCase() !== studentPassword.toLowerCase()) {
const wrongPass: BT.UpgradeUserResponse = {
ok: false,
error: "incorrect password",
};
res.send(wrongPass);
return;
}
// @ts-ignore
const uid = req.user.uid;
const couchDbUser = await getCouchDbUser(uid);
if (couchDbUser) {
const alreadyUpgraded: BT.UpgradeUserResponse = {
ok: true,
message: "user already upgraded",
};
res.send(alreadyUpgraded);
return;
}
const user = await admin.auth().getUser(uid);
const userdbPassword = generatePassword();
const newCouchDbUser: BT.CouchDbUser = {
_id: `org.couchdb.user:${user.uid}`,
type: "user",
name: user.uid,
email: user.email || "",
providerData: user.providerData,
displayName: user.displayName || "",
roles: [],
password: userdbPassword,
level: "student",
userdbPassword,
};
await usersDb.insert(newCouchDbUser);
// create wordlist database for user
const userDbName = getUserDbName(user.uid);
await nano.db.create(userDbName);
const securityInfo = {
admins: {
names: [user.uid],
roles: ["_admin"]
},
members: {
names: [user.uid],
roles: ["_admin"],
},
};
const userDb = nano.db.use(userDbName);
await userDb.insert(securityInfo, "_security");
// TODO: SET THE USERDBPASSWORD TO BE userdbPassword;
const upgraded: BT.UpgradeUserResponse = {
ok: true,
message: "user upgraded to student",
};
res.send(upgraded);
}).catch(console.error);
});
});
async function getCouchDbUser(uid: string): Promise<undefined | BT.CouchDbUser> {
const user = await usersDb.find({
selector: {
name: uid,
export const publishDictionary = functions.runWith({
timeoutSeconds: 60,
memory: "2GB"
}).https.onRequest(lingdocsAuth(
async (req, res: functions.Response<FT.PublishDictionaryResponse | FT.FunctionError>) => {
if (req.user.level !== "editor") {
res.status(403).send({ ok: false, error: "403 forbidden" });
return;
}
try {
const response = await publish();
res.send(response);
} catch (e) {
res.status(500).send({ ok: false, error: e.message });
}
});
if (!user.docs.length) {
return undefined;
}
return user.docs[0] as BT.CouchDbUser;
}
));
export const submissions = functions.runWith({
timeoutSeconds: 30,
memory: "1GB"
}).https.onRequest(lingdocsAuth(
async (req, res: functions.Response<FT.SubmissionsResponse | FT.FunctionError>) => {
if (!Array.isArray(req.body)) {
res.status(400).send({
ok: false,
error: "invalid submission",
});
return;
}
const suggestions = req.body as FT.SubmissionsRequest;
try {
const response = await receiveSubmissions(suggestions, req.user.level === "editor");
// TODO: WARN IF ANY OF THE EDITS DIDN'T HAPPEN
res.send(response);
} catch (e) {
res.status(500).send({ ok: false, error: e.message });
};
}
));

View File

@ -1,12 +0,0 @@
function stringToHex(str: string) {
const arr1 = [];
for (let n = 0, l = str.length; n < l; n ++) {
const hex = Number(str.charCodeAt(n)).toString(16);
arr1.push(hex);
}
return arr1.join('');
}
export function getUserDbName(uid: string): string {
return `userdb-${stringToHex(uid)}`;
}

View File

@ -0,0 +1,46 @@
import cors from "cors";
import fetch from "node-fetch";
import type { https, Response } from "firebase-functions";
import * as FT from "../../../website/src/lib/functions-types";
import type { LingdocsUser } from "../../../website/src/lib/account-types";
const useCors = cors({ credentials: true, origin: /\.lingdocs\.com$/ });
interface ReqWUser extends https.Request {
user: LingdocsUser;
}
/**
* creates a handler to pass to a firebase https.onRequest function
*
*/
export default function makeHandler(toRun: (req: ReqWUser, res: Response<FT.FunctionResponse>) => any | Promise<any>) {
console.log("returning handler");
return function(reqPlain: https.Request, resPlain: Response<any>) {
console.log("first level");
useCors(reqPlain, resPlain, async () => {
console.log("got in here");
const { req, res } = await authorize(reqPlain, resPlain);
if (!req) {
res.status(401).send({ ok: false, error: "unauthorized" });
return;
};
toRun(req, res);
return;
});
}
}
async function authorize(req: https.Request, res: Response<any>): Promise<{ req: ReqWUser | null, res: Response<FT.FunctionResponse> }> {
const { headers: { cookie }} = req;
if (!cookie) {
return { req: null, res };
}
const r = await fetch("https://account.lingdocs.com/api/user", { headers: { cookie }});
const { ok, user } = await r.json();
if (ok === true && user) {
req.user = user;
return { req: req as ReqWUser, res };
}
return { req: null, res };
}

View File

@ -16,7 +16,7 @@ import {
// } from "./word-list-maker";
import {
PublishDictionaryResponse,
} from "../../website/src/lib/backend-types";
} from "../../website/src/lib/functions-types";
import { Storage } from "@google-cloud/storage";
const storage = new Storage({
projectId: "lingdocs",

View File

@ -1,10 +1,11 @@
import Nano from "nano";
import { GoogleSpreadsheet } from "google-spreadsheet";
import {
dictionaryEntryTextFields,
dictionaryEntryBooleanFields,
dictionaryEntryNumberFields,
} from "@lingdocs/pashto-inflector";
import * as BT from "../../website/src/lib/backend-types";
import * as FT from "../../website/src/lib/functions-types";
import * as functions from "firebase-functions";
const fieldsForEdit = [
@ -13,11 +14,11 @@ const fieldsForEdit = [
...dictionaryEntryBooleanFields,
].filter(field => !(["ts", "i"].includes(field)));
// TODO: PASS NANO INTO FUNCTIONu
const nano = require("nano")(functions.config().couchdb.couchdb_url);
const nano = Nano(functions.config().couchdb.couchdb_url);
const reviewTasksDb = nano.db.use("review-tasks");
export async function receiveSubmissions(e: BT.SubmissionsRequest, editor: boolean): Promise<BT.SubmissionsResponse> {
export async function receiveSubmissions(e: FT.SubmissionsRequest, editor: boolean): Promise<FT.SubmissionsResponse> {
const { edits, reviewTasks } = sortSubmissions(e);
// TODO: BETTER PROMISE MULTI-TASKING
@ -25,7 +26,6 @@ export async function receiveSubmissions(e: BT.SubmissionsRequest, editor: boole
// 2. Edit dictionary entries
// 3. Add new dictionary entries
if (reviewTasks.length) {
const docs = reviewTasks.map((task) => ({
...task,
@ -111,11 +111,11 @@ export async function receiveSubmissions(e: BT.SubmissionsRequest, editor: boole
}
type SortedSubmissions = {
edits: BT.Edit[],
reviewTasks: BT.ReviewTask[],
edits: FT.Edit[],
reviewTasks: FT.ReviewTask[],
};
export function sortSubmissions(submissions: BT.Submission[]): SortedSubmissions {
export function sortSubmissions(submissions: FT.Submission[]): SortedSubmissions {
const base: SortedSubmissions = {
edits: [],
reviewTasks: [],
@ -131,12 +131,12 @@ export function sortSubmissions(submissions: BT.Submission[]): SortedSubmissions
}
type SortedEdits = {
entryEdits: BT.EntryEdit[],
newEntries: BT.NewEntry[],
entryDeletions: BT.EntryDeletion[],
entryEdits: FT.EntryEdit[],
newEntries: FT.NewEntry[],
entryDeletions: FT.EntryDeletion[],
}
export function sortEdits(edits: BT.Edit[]): SortedEdits {
export function sortEdits(edits: FT.Edit[]): SortedEdits {
const base: SortedEdits = {
entryEdits: [],
newEntries: [],

View File

@ -18,11 +18,14 @@
"classnames": "^2.2.6",
"cron": "^1.8.2",
"dayjs": "^1.10.4",
"firebase": "^8.3.0",
"lokijs": "^1.5.11",
"mousetrap": "^1.6.5",
"nano": "^9.0.3",
"node-sass": "^5.0.0",
"papaparse": "^5.3.0",
"passport-github2": "^0.1.12",
"passport-google-oauth": "^2.0.0",
"passport-twitter": "^1.0.4",
"pbf": "^3.2.1",
"pouchdb": "^7.2.2",
"react": "^17.0.1",
@ -89,6 +92,9 @@
"@types/react-helmet": "^6.1.0",
"@types/react-image-crop": "^8.1.2",
"@types/react-router-dom": "^5.1.7",
"@types/passport-github2": "^1.2.5",
"@types/passport-google-oauth": "^1.0.42",
"@types/passport-twitter": "^1.0.37",
"fake-indexeddb": "^3.1.2",
"history": "4",
"jest-fetch-mock": "^3.0.3",

View File

@ -1,779 +0,0 @@
/**
* Copyright (c) 2021 lingdocs.com
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// TODO: IndexedDB mocking not working for couchdb - it defaults to disk storage
// tslint:disable-next-line
// require("fake-indexeddb/auto");
// // tslint:disable-next-line
// const FDBFactory = require("fake-indexeddb/lib/FDBFactory");
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import { Types as T } from "@lingdocs/pashto-inflector";
import { Router, BrowserRouter } from "react-router-dom";
import App from './App';
import { dictionary } from "./lib/dictionary";
import {
mockResults,
} from "./lib/dictionary-mock-fillers";
import userEvent from '@testing-library/user-event';
import { createMemoryHistory } from 'history';
import {
loadUserInfo,
upgradeAccount,
publishDictionary,
} from "./lib/backend-calls";
import {
addSubmission, sendSubmissions,
} from "./lib/submissions";
import * as BT from "./lib/backend-types";
jest.mock("./lib/submissions");
jest.mock("./lib/backend-calls");
jest.mock("./lib/pouch-dbs");
jest.mock("./lib/wordlist-database");
jest.mock("react-ga");
const mockUserInfo = {
displayName: "Bob Billywinkle",
email: "bob@example.com",
providerData: [],
};
const mockCouchDbStudent: BT.CouchDbUser = {
_id: "123",
type: "user",
name: "123",
email: mockUserInfo.email,
providerData: [],
displayName: mockUserInfo.displayName,
roles: [],
level: "student",
userdbPassword: "12345",
}
const mockCouchDbEditor: BT.CouchDbUser = {
...mockCouchDbStudent,
level: "editor",
}
jest.mock("./lib/firebase", (): any => {
class mockAuth {
constructor() {
this.signIn = this.signIn.bind(this);
this.onAuthStateChanged = this.onAuthStateChanged.bind(this);
this.unsubscribeAll = this.unsubscribeAll.bind(this);
}
private mockUser = {
displayName: "Bob Billywinkle",
email: "bob@example.com",
providerData: [],
delete: () => {
this.currentUser = null;
return Promise.resolve();
},
};
private observers: ((user: any) => void)[] = [];
public currentUser: any = null;
onAuthStateChanged (callback: () => void) {
this.observers.push(callback);
callback();
return () => { this.unsubscribeAll() };
}
unsubscribeAll () {
this.observers = [];
}
signOut () {
this.currentUser = null;
this.observers.forEach((item) => {
item.call(undefined, this.mockUser);
});
return null;
}
signIn () {
this.currentUser = this.mockUser;
this.observers.forEach((item) => {
item.call(undefined, this.mockUser);
});
return null;
}
}
return {
auth: new mockAuth(),
};
});
jest.mock('react-firebaseui/StyledFirebaseAuth', () => function (props: any) {
return <div>
<button data-testid="mockSignInButton" onClick={props.firebaseAuth.signIn}>Sign In</button>
</div>;
});
const allMockEntries: T.DictionaryEntry[] = Object.keys(mockResults).reduce((all: T.DictionaryEntry[], key: string) => (
// @ts-ignore
[...all, ...mockResults[key]]
), []);
const fakeDictInfo: T.DictionaryInfo = {
title: "not found",
license: "not found",
release: 0,
numberOfEntries: 0,
url: "not found",
infoUrl: "not found",
};
const fakeDictionary: DictionaryAPI = {
initialize: () => Promise.resolve({
response: "loaded from saved",
dictionaryInfo: fakeDictInfo,
}),
update: () => Promise.resolve({
response: "no need for update",
dictionaryInfo: fakeDictInfo,
}),
search: function(state: State): T.DictionaryEntry[] {
if (state.options.searchType === "alphabetical") {
return state.searchValue === "ا" ? mockResults.alphabeticalA : [];
}
if (state.options.language === "Pashto") {
return state.searchValue === "کور"
? mockResults.pashtoKor
: [];
}
if (state.options.language === "English") {
return state.searchValue === "tired"
? mockResults.englishTired as T.DictionaryEntry[]
: [];
}
return [];
},
getNewWordsThisMonth: function(): T.DictionaryEntry[] {
return [];
},
findOneByTs: function(ts: number): T.DictionaryEntry | undefined {
return allMockEntries.find((entry) => entry.ts === ts);
},
findRelatedEntries: function(entry: T.DictionaryEntry): T.DictionaryEntry[] {
// TODO: Better mock
return allMockEntries.filter((e) => e.e.includes("house"));
},
exactPashtoSearch: function(search: string ): T.DictionaryEntry[] {
return [];
},
};
const dictionaryPublishResponse = "dictionary published";
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(dictionary, "initialize").mockImplementation(() => Promise.resolve("loaded from saved"));
jest.spyOn(dictionary, "search").mockImplementation(fakeDictionary.search);
jest.spyOn(dictionary, "findOneByTs").mockImplementation(fakeDictionary.findOneByTs);
jest.spyOn(dictionary, "findRelatedEntries").mockImplementation(fakeDictionary.findRelatedEntries);
jest.spyOn(dictionary, "exactPashtoSearch").mockImplementation(fakeDictionary.exactPashtoSearch);
loadUserInfo.mockResolvedValue(undefined);
// fetchSuggestions.mockResolvedValue({ ok: true, suggestions: [] });
upgradeAccount.mockImplementation(async (password: string): Promise<BT.UpgradeUserResponse> => {
if (password === "correct password") {
return { ok: true, message: "user upgraded to student" };
}
return {
ok: false,
error: "incorrect password",
};
});
publishDictionary.mockResolvedValue(dictionaryPublishResponse);
localStorage.clear();
// indexedDB = new FDBFactory();
});
// TODO: feed it a fake mini dictionary through JSON - to get more realistic testing
// don't mock the dictionary object
test('renders loading', async () => {
jest.spyOn(dictionary, "initialize").mockImplementation(() => Promise.resolve("loaded from saved"));
render(<BrowserRouter><App /></BrowserRouter>);
const text = screen.getByText(/loading/i);
expect(text).toBeInTheDocument();
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
});
test('renders error loading', async () => {
jest.spyOn(dictionary, "initialize").mockImplementation(() => Promise.reject());
render(<BrowserRouter><App /></BrowserRouter>);
await waitFor(() => screen.getByText(/error loading/i));
});
test('renders dictionary loaded', async () => {
render(<BrowserRouter><App /></BrowserRouter>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
});
test('searches on type', async () => {
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
// Search Pashto
let searchInput = screen.getByPlaceholderText(/search pashto/i);
userEvent.type(searchInput, "کور");
mockResults.pashtoKor.slice(0, 10).forEach((result) => {
expect(screen.getAllByText(result.e)[0]).toBeInTheDocument();
expect(screen.getAllByText(result.p)[0]).toBeInTheDocument();
expect(screen.getAllByText(result.f)[0]).toBeInTheDocument();
});
expect(history.location.pathname).toBe("/search");
// Clear
userEvent.type(searchInput, "{backspace}{backspace}{backspace}");
mockResults.pashtoKor.slice(0, 10).forEach((result) => {
expect(screen.queryByText(result.e)).toBeNull();
expect(screen.queryByText(result.p)).toBeNull();
expect(screen.queryByText(result.f)).toBeNull();
});
expect(history.location.pathname).toBe("/");
// Switch To English
const languageToggle = screen.getByTestId("languageToggle");
userEvent.click(languageToggle);
expect(screen.queryByPlaceholderText(/search pashto/i)).toBeNull();
searchInput = screen.getByPlaceholderText(/search english/i);
userEvent.type(searchInput, "tired");
mockResults.englishTired.slice(0, 10).forEach((result) => {
expect(screen.getAllByText(result.e)[0]).toBeInTheDocument();
expect(screen.getAllByText(result.p)[0]).toBeInTheDocument();
expect(screen.getAllByText(result.f)[0]).toBeInTheDocument();
});
expect(history.location.pathname).toBe("/search");
// Clear
const clearButton = screen.getByTestId("clearButton");
userEvent.click(clearButton);
mockResults.englishTired.slice(0, 10).forEach((result) => {
expect(screen.queryByText(result.e)).toBeNull();
expect(screen.queryByText(result.p)).toBeNull();
expect(screen.queryByText(result.f)).toBeNull();
});
// Search again
userEvent.type(searchInput, "tired");
mockResults.englishTired.slice(0, 10).forEach((result) => {
expect(screen.getAllByText(result.e)[0]).toBeInTheDocument();
expect(screen.getAllByText(result.p)[0]).toBeInTheDocument();
expect(screen.getAllByText(result.f)[0]).toBeInTheDocument();
});
// Go back
history.goBack();
expect(history.location.pathname).toBe("/");
});
test('does alphabetical browse search', async () => {
render(<BrowserRouter><App /></BrowserRouter>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
expect(screen.queryByText(/alphabetical browsing mode/i)).toBeNull();
const searchTypeButton = screen.getByTestId("searchTypeToggle");
userEvent.click(searchTypeButton);
expect(screen.queryByText(/alphabetical browsing mode/i)).toBeInTheDocument();
const searchInput = screen.getByPlaceholderText(/browse/i);
userEvent.type(searchInput, "ا");
mockResults.alphabeticalA.forEach((entry) => {
expect(screen.queryAllByText(entry.e)).toBeTruthy;
});
userEvent.type(searchInput, "{backspace}");
userEvent.type(searchInput, "ززززز");
expect(screen.queryByText(/no results found/i)).toBeInTheDocument();
expect(screen.queryByText(/You are using alphabetical browsing mode/i)).toBeInTheDocument();
});
test('isolates word on click', async () => {
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
let searchInput = screen.getByPlaceholderText(/search pashto/i);
userEvent.type(searchInput, "کور");
expect(history.location.pathname).toBe("/search");
const firstResult = screen.getByText(mockResults.pashtoKor[0].e);
userEvent.click(firstResult);
expect(screen.getByText(/related words/i)).toBeInTheDocument();
expect(history.location.pathname).toBe("/word");
const params = new URLSearchParams(history.location.search);
const wordId = params.get("id");
expect(wordId && parseInt(wordId)).toBe(mockResults.pashtoKor[0].ts);
// should leave word when going back
history.goBack();
expect(history.location.pathname).toBe("/search");
// go back to word when going forward
history.goForward();
expect(history.location.pathname).toBe("/word");
expect(screen.getByText(/related words/i)).toBeInTheDocument();
// leave word when clearing
const clearButton = screen.getByTestId("clearButton");
userEvent.click(clearButton);
expect(history.location.pathname).toBe("/")
expect(screen.queryByText(/related words/i)).toBeNull();
userEvent.type(searchInput, "کور");
expect(history.location.pathname).toBe("/search");
const firstResultb = screen.getByText(mockResults.pashtoKor[0].e);
userEvent.click(firstResultb);
expect(history.location.pathname).toBe("/word");
// leave word when searching
const input = screen.getByTestId("searchInput");
userEvent.type(input, "سړی");
expect(history.location.pathname).toBe("/search");
expect(screen.queryByText(/related words/i)).toBeNull();
expect(screen.queryByText(/no results found/i)).toBeTruthy();
const clearButton1 = screen.getByTestId("clearButton");
userEvent.click(clearButton1);
expect(history.location.pathname).toBe("/");
// search click on a word again
userEvent.type(searchInput, "کور");
expect(history.location.pathname).toBe("/search");
const firstResultc = screen.getByText(mockResults.pashtoKor[0].e);
userEvent.click(firstResultc);
expect(history.location.pathname).toBe("/word")
expect(screen.getByText(/related words/i)).toBeInTheDocument();
expect(history.location.search).toBe(`?id=${mockResults.pashtoKor[0].ts}`);
const relatedEntry = mockResults.pashtoKor.filter((entry) => entry.e.includes("house"))[1] as T.DictionaryEntry;
const otherResult = screen.getByText(relatedEntry.p);
userEvent.click(otherResult);
expect(history.location.pathname).toBe(`/word`);
expect(history.location.search).toBe(`?id=${relatedEntry.ts}`);
// search for a word that uses a complement
userEvent.click(clearButton1);
const languageToggle = screen.getByTestId("languageToggle");
userEvent.click(languageToggle);
userEvent.type(searchInput, "tired");
const resultWComplement = mockResults.englishTired.find((entry) => entry.c.includes(" comp.") && entry.l) as T.DictionaryEntry;
userEvent.click(screen.getByText(resultWComplement.e));
expect(history.location.pathname).toBe(`/word`);
expect(history.location.search).toBe(`?id=${resultWComplement.ts}`);
expect(screen.queryByText(resultWComplement.e)).toBeInTheDocument();
});
test('shows about page', async () => {
render(<BrowserRouter><App /></BrowserRouter>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
const aboutButton = screen.getByText(/about/i);
userEvent.click(aboutButton);
expect(screen.queryByText(/inspiration and sources/i)).toBeInTheDocument();
const homeButton = screen.getByText(/home/i);
userEvent.click(homeButton);
expect(screen.queryByText(/inspiration and sources/i)).toBeNull();
});
test('starts on about page when starting from /about', async () => {
const history = createMemoryHistory();
history.push("/about");
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/about/i));
expect(screen.queryByText(/inspiration and sources/i)).toBeInTheDocument();
const homeButton = screen.getByText(/home/i);
userEvent.click(homeButton);
expect(screen.queryByText(/inspiration and sources/i)).toBeNull();
});
test('shows settings page / settings page works', async () => {
render(<BrowserRouter><App /></BrowserRouter>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
const settingsButton = screen.getAllByText(/settings/i)[0];
userEvent.click(settingsButton);
expect(screen.queryByText(/diacritics/i)).toBeInTheDocument();
const homeButton = screen.getByText(/home/i);
userEvent.click(homeButton);
expect(screen.queryByText(/diacritics/i)).toBeNull();
// play with settings
const settingsButton1 = screen.getAllByText(/settings/i)[0];
userEvent.click(settingsButton1);
const darkButton = screen.getByText(/dark/i);
userEvent.click(darkButton);
const lightButton = screen.getByText(/light/i);
userEvent.click(lightButton);
});
test('starts on settings page when starting from /settings', async () => {
const history = createMemoryHistory();
history.push("/settings");
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/settings/i));
expect(screen.queryByText(/diacritics/i)).toBeInTheDocument();
const homeButton = screen.getByText(/home/i);
userEvent.click(homeButton);
expect(screen.queryByText(/diacritics/i)).toBeNull();
});
test('persists settings', async () => {
const history = createMemoryHistory();
history.push("/settings");
const { unmount, rerender } = render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/settings/i));
const darkButton = screen.getByText(/dark/i);
const lightButton = screen.getByText(/light/i);
expect(darkButton.className.toString().includes("active")).toBe(false);
expect(lightButton.className.toString().includes("active")).toBe(true);
userEvent.click(darkButton);
expect(darkButton.className.toString().includes("active")).toBe(true);
expect(lightButton.className.toString().includes("active")).toBe(false);
const afghanSp = screen.getByText(/afghan/i);
const pakSp = screen.getByText(/pakistani ی/i);
expect(afghanSp.className.toString().includes("active")).toBe(true);
expect(pakSp.className.toString().includes("active")).toBe(false);
userEvent.click(pakSp);
expect(afghanSp.className.toString().includes("active")).toBe(false);
expect(pakSp.className.toString().includes("active")).toBe(true);
unmount();
rerender(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/settings/i));
const afghanSp1 = screen.getByText(/afghan/i);
const pakSp1 = screen.getByText(/pakistani ی/i);
const darkButton1 = screen.getByText(/dark/i);
const lightButton1 = screen.getByText(/light/i);
expect(darkButton1.className.toString().includes("active")).toBe(true);
expect(lightButton1.className.toString().includes("active")).toBe(false);
expect(afghanSp1.className.toString().includes("active")).toBe(false);
expect(pakSp1.className.toString().includes("active")).toBe(true);
});
test('starts on home page when starting on invalid page', async () => {
const history = createMemoryHistory();
history.push("/search");
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/lingdocs pashto dictionary/i));
expect(history.location.pathname).toBe("/");
});
test('starts on home page when starting on an unauthorized page', async () => {
const history = createMemoryHistory();
history.push("/edits");
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/lingdocs pashto dictionary/i));
expect(history.location.pathname).toBe("/");
});
test('starts on isolated word when starting from /word?id=_____', async () => {
const history = createMemoryHistory();
const entry = mockResults.pashtoKor[0];
history.push(`/word?id=${entry.ts}`);
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/related words/i));
expect(screen.queryAllByText(entry.p)).toBeTruthy();
});
test('says word not found if starting on /word?id=_____ with an unfound id', async () => {
const history = createMemoryHistory();
const entry = mockResults.pashtoKor[0];
history.push(`/word?id=${entry.ts + 20000}`);
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/word not found/i));
});
test('goes to home page if starts with /word but without an id param', async () => {
const history = createMemoryHistory();
const entry = mockResults.pashtoKor[0];
history.push(`/word?badparam=${entry.ts}`);
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getAllByText(/lingdocs pashto dictionary/i));
expect(history.location.pathname).toBe("/");
});
test('sign in and out of account works', async () => {
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
userEvent.click(screen.getByText(/sign in/i));
expect(screen.queryByText(/sign in to be able to/i)).toBeInTheDocument();
userEvent.click(screen.getByTestId("mockSignInButton"));
expect(screen.queryByText(new RegExp(mockUserInfo.email))).toBeInTheDocument();
expect(screen.queryByText(new RegExp(mockUserInfo.displayName))).toBeInTheDocument();
userEvent.click(screen.getByText(/home/i));
// now to get back to the account page there should be an account button, not a sign-in button
expect(screen.queryByText(/sign in/i)).toBeNull();
userEvent.click(screen.getByText(/account/i));
userEvent.click(screen.getByTestId("signoutButton"));
expect(history.location.pathname).toBe("/");
expect(screen.getByText(/sign in/i)).toBeInTheDocument();
// sign back in and delete account
userEvent.click(screen.getByText(/sign in/i));
userEvent.click(screen.getByTestId("mockSignInButton"));
userEvent.click(screen.getByText(/delete account/i));
expect(screen.queryByText(/yes, delete my account/i)).toBeInTheDocument();
userEvent.click(screen.getByText(/no, cancel/i));
await waitForElementToBeRemoved(() => screen.queryByText(/yes, delete my account/i));
userEvent.click(screen.getByText(/delete account/i));
userEvent.click(screen.getByText(/yes, delete my account/i));
await waitFor(() => screen.queryByText(/Your account has been deleted/i));
expect(history.location.pathname).toBe("/account");
userEvent.click(screen.getAllByText(/home/i)[0]);
expect(history.location.pathname).toBe("/");
});
test('word edit suggestion works', async () => {
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
// first try without signing in
expect(screen.getByText(/sign in/i)).toBeInTheDocument();
let searchInput = screen.getByPlaceholderText(/search pashto/i);
userEvent.type(searchInput, "کور");
expect(history.location.pathname).toBe("/search");
let firstResult = screen.getByText(mockResults.pashtoKor[0].e);
userEvent.click(firstResult);
expect(screen.getByText(/related words/i)).toBeInTheDocument();
// the edit button should not be there
expect(screen.queryByTestId(/editEntryButton/i)).toBeNull();
// nor should the finalEdit button
expect(screen.queryByTestId(/finalEditEntryButton/i)).toBeNull();
// sign in to be able to suggest an edit
history.goBack();
history.goBack();
userEvent.click(screen.getByText(/sign in/i));
userEvent.click(screen.getByTestId("mockSignInButton"));
expect(sendSubmissions).toHaveBeenCalledTimes(1);
userEvent.click(screen.getByText(/home/i));
userEvent.type(searchInput, "کور");
firstResult = screen.getByText(mockResults.pashtoKor[0].e);
userEvent.click(firstResult);
// the final edit button should not be there
expect(screen.queryByTestId(/finalEditEntryButton/i)).toBeNull();
userEvent.click(screen.getByTestId(/editEntryButton/i));
userEvent.type(screen.getByLabelText(/Suggest correction\/edit:/i), "my suggestion");
userEvent.click(screen.getByText(/cancel/i));
expect(screen.queryByLabelText(/Suggest correction\/edit:/i)).toBeNull();
userEvent.click(screen.getByTestId(/editEntryButton/i));
userEvent.type(screen.getByLabelText(/Suggest correction\/edit:/i), "my comment");
userEvent.click(screen.getByText(/submit/i));
expect(screen.queryByText(/Thank you for your help!/i)).toBeInTheDocument();
expect(addSubmission).toHaveBeenCalledTimes(1);
expect(addSubmission).toHaveBeenCalledWith(expect.objectContaining({
entry: mockResults.pashtoKor[0],
comment: "my comment",
}), "basic");
history.goBack();
history.goBack();
userEvent.click(screen.getByText(/account/i));
userEvent.click(screen.getByText(/sign out/i));
});
test('upgrade account works', async () => {
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
userEvent.click(screen.getByText(/sign in/i));
expect(screen.queryByText(/sign in to be able to/i)).toBeInTheDocument();
userEvent.click(screen.getByTestId("mockSignInButton"));
expect(screen.queryByText(new RegExp(mockUserInfo.email))).toBeInTheDocument();
expect(screen.queryByText(new RegExp(mockUserInfo.displayName))).toBeInTheDocument();
expect(screen.queryByText(/level: basic/i)).toBeInTheDocument();
userEvent.click(screen.getByText(/upgrade account/i));
userEvent.type(screen.getByLabelText(/upgrade password:/i), "something wrong");
userEvent.click(screen.getByText(/upgrade my account/i));
await waitFor(() => screen.queryByText(/incorrect password/i));
userEvent.click(screen.getByText(/cancel/i));
await waitFor(() => screen.getByText(/upgrade account/i));
userEvent.click(screen.getByText(/upgrade account/i));
userEvent.type(screen.getByLabelText(/upgrade password:/i), "correct password");
loadUserInfo.mockResolvedValue(mockCouchDbStudent);
userEvent.click(screen.getByText(/upgrade my account/i));
await waitForElementToBeRemoved(() => screen.getAllByText(/upgrade account/i));
userEvent.click(screen.getByText(/sign out/i));
});
test('editor priveledges show up and allow you to make a final edit of an entry', async () => {
loadUserInfo.mockResolvedValue(mockCouchDbEditor);
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
userEvent.click(screen.getByText(/sign in/i));
userEvent.click(screen.getByTestId("mockSignInButton"));
await waitFor(() => screen.getByText(/account level: editor/i));
expect(sendSubmissions).toHaveBeenCalledTimes(1);
userEvent.click(screen.getByText(/home/i));
expect(screen.getByText(/editor priveleges active/i)).toBeInTheDocument()
let searchInput = screen.getByPlaceholderText(/search pashto/i);
userEvent.type(searchInput, "کور");
expect(history.location.pathname).toBe("/search");
let firstResult = screen.getByText(mockResults.pashtoKor[0].e);
userEvent.click(firstResult);
expect(screen.getByText(/related words/i)).toBeInTheDocument();
// the edit button should be there
expect(screen.getByTestId("editEntryButton")).toBeInTheDocument();
// the final edit button should also be there
expect(screen.getByTestId("finalEditEntryButton")).toBeInTheDocument();
userEvent.click(screen.getByTestId("finalEditEntryButton"));
userEvent.type(screen.getByLabelText(/english/i), " adding more in english");
userEvent.click(screen.getByLabelText(/no inflection/i));
userEvent.click(screen.getByText(/submit/i));
expect(screen.getByText(/edit submitted\/saved/i)).toBeInTheDocument();
expect(addSubmission).toHaveBeenCalledTimes(1);
expect(addSubmission).toHaveBeenCalledWith(expect.objectContaining({
type: "entry edit",
entry: {
...mockResults.pashtoKor[0],
e: mockResults.pashtoKor[0].e + " adding more in english",
noInf: true,
},
}), "editor");
userEvent.click(screen.getByTestId(/navItemHome/i));
userEvent.click(screen.getByText(/account/i));
userEvent.click(screen.getByText(/sign out/i));
});
test('editor should be able to publish the dictionary', async () => {
loadUserInfo.mockResolvedValue(undefined);
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
userEvent.click(screen.getByText(/sign in/i));
userEvent.click(screen.getByTestId("mockSignInButton"));
await waitFor(() => screen.getByText(/account level: basic/i));
// publish dictionary option should not be available to non editor
expect(screen.queryByText(/publish dictionary/i)).toBeNull();
userEvent.click(screen.getByText(/sign out/i));
userEvent.click(screen.getByText(/sign in/i));
loadUserInfo.mockResolvedValue(mockCouchDbStudent);
userEvent.click(screen.getByTestId("mockSignInButton"));
await waitFor(() => screen.getByText(/account level: student/i));
// publish dictionary option should not be available to non editor
expect(screen.queryByText(/publish dictionary/i)).toBeNull();
userEvent.click(screen.getByText(/sign out/i));
userEvent.click(screen.getByText(/sign in/i));
loadUserInfo.mockResolvedValue(mockCouchDbEditor);
userEvent.click(screen.getByTestId("mockSignInButton"));
await waitFor(() => screen.getByText(/account level: editor/i));
// publish dictionary options should only be available to editor
userEvent.click(screen.getByText(/publish dictionary/i));
expect(screen.getByText(/processing\.\.\./i)).toBeInTheDocument();
await waitFor(() => screen.getByText(JSON.stringify(dictionaryPublishResponse, null, "\\t")));
userEvent.click(screen.getByText(/sign out/i));
});
test('wordlist should be hidden from basic users and available for upgraded users', async () => {
loadUserInfo.mockResolvedValue(undefined);
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
// doesn't exist on basic accounts signed in or not
expect(screen.queryByText(/wordlist/i)).toBeNull();
userEvent.click(screen.getByText(/sign in/i));
userEvent.click(screen.getByTestId("mockSignInButton"));
await waitFor(() => screen.queryByText(mockUserInfo.displayName));
userEvent.click(screen.getByText(/home/i));
expect(screen.queryByText(/wordlist/i)).toBeNull();
userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور");
expect(history.location.pathname).toBe("/search");
userEvent.click(screen.getByText(mockResults.pashtoKor[0].e));
expect(screen.getByText(/related words/i)).toBeInTheDocument();
// shouldn't be able to see the add to wordlist star
expect(screen.queryByTestId("emptyStarButton")).toBeNull();
expect(screen.queryByTestId("fullStarButton")).toBeNull();
history.goBack();
history.goBack();
userEvent.click(screen.getByText(/account/i));
userEvent.click(screen.getByText(/sign out/i));
loadUserInfo.mockResolvedValue(mockCouchDbStudent);
// does exist for student account
userEvent.click(screen.getByText(/sign in/i));
userEvent.click(screen.getByTestId("mockSignInButton"));
await waitFor(() => screen.getByText(/level: student/i));
userEvent.click(screen.getByText(/home/i));
expect(screen.getByText(/wordlist/i)).toBeInTheDocument();
userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور");
expect(history.location.pathname).toBe("/search");
userEvent.click(screen.getByText(mockResults.pashtoKor[0].e));
expect(screen.getByText(/related words/i)).toBeInTheDocument();
// should be able to see the word list star
expect(screen.queryByTestId("emptyStarButton")).toBeInTheDocument();
history.goBack();
history.goBack();
userEvent.click(screen.getByText(/account/i));
userEvent.click(screen.getByText(/sign out/i));
loadUserInfo.mockResolvedValue(mockCouchDbEditor);
// also exists for editor account
userEvent.click(screen.getByText(/sign in/i));
userEvent.click(screen.getByTestId("mockSignInButton"));
await waitFor(() => screen.getByText(/level: editor/i));
userEvent.click(screen.getByText(/home/i));
expect(screen.getByText(/wordlist/i)).toBeInTheDocument();
userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور");
expect(history.location.pathname).toBe("/search");
userEvent.click(screen.getByText(mockResults.pashtoKor[0].e));
expect(screen.getByText(/related words/i)).toBeInTheDocument();
expect(screen.getByTestId("emptyStarButton")).toBeInTheDocument();
history.goBack();
history.goBack();
userEvent.click(screen.getByText(/account/i));
userEvent.click(screen.getByText(/sign out/i));
});
// test('wordlist adding and removing should work', async () => {
// const wordNotes = "my test notes";
// const noteAddition = " and some more";
// const wordToAdd = mockResults.pashtoKor[0];
// loadUserInfo.mockResolvedValue(mockCouchDbStudent);
// const history = createMemoryHistory();
// render(<Router history={history}><App /></Router>);
// await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
// userEvent.click(screen.getByText(/sign in/i));
// userEvent.click(screen.getByTestId("mockSignInButton"));
// await waitFor(() => screen.getByText(/level: student/i));
// userEvent.click(screen.getByText(/home/i));
// expect(screen.getByText(/wordlist/i)).toBeInTheDocument();
// userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور");
// expect(history.location.pathname).toBe("/search");
// userEvent.click(screen.getByText(wordToAdd.e));
// // should be able to see the word list star
// expect(screen.getByTestId("emptyStarButton")).toBeInTheDocument();
// userEvent.click(screen.getByTestId("emptyStarButton"));
// await waitFor(() => screen.getByTestId("fullStarButton"));
// userEvent.type(screen.getByTestId("wordlistWordContextForm"), wordNotes);
// userEvent.click(screen.getByText(/save context/i));
// userEvent.click(screen.getByTestId("backButton"));
// userEvent.click(screen.getByTestId("backButton"));
// // should have one word in wordlist for review
// userEvent.click(screen.getByText("Wordlist (1)"));
// // should appear on screen with notes
// userEvent.click(screen.getByText(/browse/i));
// expect(screen.getByText(wordNotes)).toBeInTheDocument();
// // notes should be editable
// userEvent.click(screen.getByText(wordToAdd.e));
// userEvent.type(screen.getByText(wordNotes), noteAddition);
// userEvent.click(screen.getByText(/save context/i));
// await waitFor(() => screen.getByText(/context saved/i));
// userEvent.click(screen.getByText(wordToAdd.e));
// expect(screen.queryByText(/context saved/)).toBeNull();
// expect(screen.getByText(wordNotes + noteAddition)).toBeInTheDocument();
// // should be able to delete from the browsing screen
// userEvent.click(screen.getByText(wordToAdd.e));
// userEvent.click(screen.getByText(/delete/i));
// await waitForElementToBeRemoved(() => screen.getByText(wordToAdd.e));
// userEvent.click(screen.getByText(/home/i));
// // now try adding and deleting a word from the isolated word screen
// userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور");
// expect(history.location.pathname).toBe("/search");
// userEvent.click(screen.getByText(wordToAdd.e));
// expect(screen.getByTestId("emptyStarButton")).toBeInTheDocument();
// userEvent.click(screen.getByTestId("emptyStarButton"));
// await waitFor(() => screen.getByTestId("fullStarButton"));
// userEvent.click(screen.getByTestId("backButton"));
// userEvent.click(screen.getByTestId("backButton"));
// userEvent.click(screen.getByText(/wordlist.*/i));
// userEvent.click(screen.getByText(/browse/i));
// // go back to isolated word screen from the dictionary entry button
// userEvent.click(screen.getByText(wordToAdd.e));
// userEvent.click(screen.getByText(/dictionary entry/i));
// expect(screen.getByText(/related words/i)).toBeInTheDocument();
// expect(history.location.pathname).toBe("/word");
// // delete the word from the wordlist from the isolated word screen
// userEvent.click(screen.getByTestId("fullStarButton"));
// userEvent.click(screen.getByText(/cancel/i));
// userEvent.click(screen.getByTestId("fullStarButton"));
// userEvent.click(screen.getByTestId("confirmDeleteFromWordlist"));
// await waitFor(() => screen.getByTestId("emptyStarButton"));
// userEvent.click(screen.getByTestId("backButton"));
// expect(screen.queryByText(/wordlist is empty/i)).toBeInTheDocument();
// });
// TODO: REMOVE waitFor(() => screen.//queryByText// )
// TODO: Test review

View File

@ -6,6 +6,9 @@
*
*/
// TODO: Put the DB sync on the localDb object, and then have it cancel()'ed and removed as part of the deinitialization
// sync on initialization and cancel sync on de-initialization
import { Component } from "react";
import { defaultTextOptions } from "@lingdocs/pashto-inflector";
import { withRouter, Route, RouteComponentProps, Link } from "react-router-dom";
@ -21,32 +24,37 @@ import ReviewTasks from "./screens/ReviewTasks";
import EntryEditor from "./screens/EntryEditor";
import IsolatedEntry from "./screens/IsolatedEntry";
import Wordlist from "./screens/Wordlist";
import { saveOptions, readOptions } from "./lib/options-storage";
import { wordlistEnabled } from "./lib/level-management";
import {
saveOptions,
readOptions,
saveUser,
readUser,
} from "./lib/local-storage";
import { dictionary, pageSize } from "./lib/dictionary";
import optionsReducer from "./lib/options-reducer";
import {
optionsReducer,
textOptionsReducer,
resolveTextOptions,
removePTextSize,
} from "./lib/options-reducer";
import hitBottom from "./lib/hitBottom";
import getWordId from "./lib/get-word-id";
import { auth } from "./lib/firebase";
import { CronJob } from "cron";
import Mousetrap from "mousetrap";
import {
sendSubmissions,
} from "./lib/submissions";
import {
loadUserInfo,
getUser,
updateUserTextOptionsRecord,
} from "./lib/backend-calls";
import * as BT from "./lib/backend-types";
import {
getWordlist,
} from "./lib/wordlist-database";
import {
wordlistEnabled,
} from "./lib/level-management";
import {
deInitializeLocalDb,
initializeLocalDb,
startLocalDbSync,
getLocalDbName,
startLocalDbs,
stopLocalDbs,
getAllDocsLocalDb,
} from "./lib/pouch-dbs";
import {
@ -55,6 +63,7 @@ import {
import {
textBadge,
} from "./lib/badges";
import * as AT from "./lib/account-types";
import ReactGA from "react-ga";
// tslint:disable-next-line
import "@fortawesome/fontawesome-free/css/all.css";
@ -62,6 +71,7 @@ import "./custom-bootstrap.scss";
// tslint:disable-next-line: ordered-imports
import "./App.css";
import classNames from "classnames";
import { getTextOptions } from "./lib/get-text-options";
// to allow Moustrap key combos even when input fields are in focus
Mousetrap.prototype.stopCallback = function () {
@ -89,13 +99,16 @@ class App extends Component<RouteComponentProps, State> {
this.state = {
dictionaryStatus: "loading",
dictionaryInfo: undefined,
// TODO: Choose between the saved options and the options in the saved user
options: savedOptions ? savedOptions : {
language: "Pashto",
searchType: "fuzzy",
theme: /* istanbul ignore next */ (window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
textOptions: defaultTextOptions,
level: "basic",
textOptionsRecord: {
lastModified: Date.now() as AT.TimeStamp,
textOptions: defaultTextOptions,
},
wordlistMode: "browse",
wordlistReviewLanguage: "Pashto",
wordlistReviewBadge: true,
@ -107,13 +120,15 @@ class App extends Component<RouteComponentProps, State> {
results: [],
wordlist: [],
reviewTasks: [],
user: readUser(),
};
this.handleOptionsUpdate = this.handleOptionsUpdate.bind(this);
this.handleTextOptionsUpdate = this.handleTextOptionsUpdate.bind(this);
this.handleSearchValueChange = this.handleSearchValueChange.bind(this);
this.handleIsolateEntry = this.handleIsolateEntry.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.handleGoBack = this.handleGoBack.bind(this);
this.handleLoadUserInfo = this.handleLoadUserInfo.bind(this);
this.handleLoadUser = this.handleLoadUser.bind(this);
this.handleRefreshWordlist = this.handleRefreshWordlist.bind(this);
this.handleRefreshReviewTasks = this.handleRefreshReviewTasks.bind(this);
this.handleDictionaryUpdate = this.handleDictionaryUpdate.bind(this);
@ -124,20 +139,20 @@ class App extends Component<RouteComponentProps, State> {
if (!possibleLandingPages.includes(this.props.location.pathname)) {
this.props.history.replace("/");
}
if (prod && (this.state.options.level !== "editor")) {
if (prod && (!(this.state.user?.level === "editor"))) {
ReactGA.pageview(window.location.pathname + window.location.search);
}
dictionary.initialize().then((r) => {
this.checkUserCronJob.start();
this.networkCronJob.start();
this.setState({
dictionaryStatus: "ready",
dictionaryInfo: r.dictionaryInfo,
});
this.handleLoadUser();
// incase it took forever and timed out - might need to reinitialize the wordlist here ??
if (wordlistEnabled(this.state)) {
initializeLocalDb("wordlist", this.handleRefreshWordlist, auth.currentUser ? auth.currentUser.uid : undefined);
}
if (this.state.options.level === "editor") {
initializeLocalDb("reviewTasks", this.handleRefreshReviewTasks);
if (this.state.user) {
startLocalDbs(this.state.user, { wordlist: this.handleRefreshWordlist, reviewTasks: this.handleRefreshReviewTasks });
}
if (this.props.location.pathname === "/word") {
const wordId = getWordId(this.props.location.search);
@ -182,32 +197,6 @@ class App extends Component<RouteComponentProps, State> {
}
});
}
this.unregisterAuthObserver = auth.onAuthStateChanged((user) => {
if (user) {
if (wordlistEnabled(this.state)) {
initializeLocalDb("wordlist", this.handleRefreshWordlist, user.uid);
}
sendSubmissions();
this.handleLoadUserInfo().catch(console.error);
this.networkCronJob.stop();
this.networkCronJob.start();
} else {
// signed out
this.networkCronJob.stop();
if (this.wordlistSync) {
this.wordlistSync.cancel();
this.wordlistSync = undefined;
}
if (this.reviewTastsSync) {
this.reviewTastsSync.cancel();
this.reviewTastsSync = undefined;
}
deInitializeLocalDb("wordlist");
deInitializeLocalDb("reviewTasks");
this.handleOptionsUpdate({ type: "changeUserLevel", payload: "basic" });
}
this.forceUpdate();
});
Mousetrap.bind(["ctrl+down", "ctrl+up", "command+down", "command+up"], (e) => {
if (e.repeat) return;
this.handleOptionsUpdate({ type: "toggleLanguage" });
@ -218,7 +207,7 @@ class App extends Component<RouteComponentProps, State> {
});
Mousetrap.bind(["ctrl+\\", "command+\\"], (e) => {
if (e.repeat) return;
if (this.state.options.level === "basic") return;
if (this.state.user?.level === "basic") return;
if (this.props.location.pathname !== "/wordlist") {
this.props.history.push("/wordlist");
} else {
@ -229,14 +218,9 @@ class App extends Component<RouteComponentProps, State> {
public componentWillUnmount() {
window.removeEventListener("scroll", this.handleScroll);
this.unregisterAuthObserver();
this.checkUserCronJob.stop();
this.networkCronJob.stop();
if (this.wordlistSync) {
this.wordlistSync.cancel();
}
if (this.reviewTastsSync) {
this.reviewTastsSync.cancel();
}
stopLocalDbs();
Mousetrap.unbind(["ctrl+down", "ctrl+up", "command+down", "command+up"]);
Mousetrap.unbind(["ctrl+b", "command+b"]);
Mousetrap.unbind(["ctrl+\\", "command+\\"]);
@ -244,7 +228,7 @@ class App extends Component<RouteComponentProps, State> {
public componentDidUpdate(prevProps: RouteComponentProps) {
if (this.props.location.pathname !== prevProps.location.pathname) {
if (prod && (this.state.options.level !== "editor")) {
if (prod && (!(this.state.user?.level === "editor"))) {
ReactGA.pageview(window.location.pathname + window.location.search);
}
if (this.props.location.pathname === "/") {
@ -256,12 +240,12 @@ class App extends Component<RouteComponentProps, State> {
page: 1,
});
}
if (editorOnlyPages.includes(this.props.location.pathname) && this.state.options.level !== "editor") {
if (editorOnlyPages.includes(this.props.location.pathname) && !(this.state.user?.level === "editor")) {
this.props.history.replace("/");
}
}
if (getWordId(this.props.location.search) !== getWordId(prevProps.location.search)) {
if (prod && (this.state.options.level !== "editor")) {
if (prod && ((this.state.user?.level !== "editor"))) {
ReactGA.pageview(window.location.pathname + window.location.search);
}
const wordId = getWordId(this.props.location.search);
@ -277,54 +261,42 @@ class App extends Component<RouteComponentProps, State> {
// }
}
private unregisterAuthObserver() {
// will be filled in on mount
}
private wordlistSync: PouchDB.Replication.Sync<any> | undefined = undefined;
private reviewTastsSync: PouchDB.Replication.Sync<any> | undefined = undefined;
private async handleLoadUserInfo(): Promise<BT.CouchDbUser | undefined> {
private async handleLoadUser(): Promise<void> {
try {
const userInfo = await loadUserInfo();
const differentUserInfoLevel = userInfo && (userInfo.level !== this.state.options.level);
const needToDowngrade = (!userInfo && wordlistEnabled(this.state));
if (differentUserInfoLevel || needToDowngrade) {
this.handleOptionsUpdate({
type: "changeUserLevel",
payload: userInfo ? userInfo.level : "basic",
});
const prevUser = this.state.user;
const userOnServer = await getUser();
if (userOnServer === "offline") return;
if (userOnServer) sendSubmissions();
if (!userOnServer) {
this.setState({ user: undefined });
saveUser(undefined);
return;
}
if (!userInfo) return undefined;
// only sync wordlist for upgraded accounts
if (userInfo && wordlistEnabled(userInfo.level)) {
// TODO: GO OVER THIS HORRENDOUS BLOCK
if (userInfo.level === "editor") {
initializeLocalDb("reviewTasks", this.handleRefreshReviewTasks);
if (!this.reviewTastsSync) {
this.reviewTastsSync = startLocalDbSync("reviewTasks", { name: userInfo.name, password: userInfo.userdbPassword });
}
}
const wordlistName = getLocalDbName("wordlist") ?? "";
const usersWordlistInitialized = wordlistName.includes(userInfo.name);
if (this.wordlistSync && usersWordlistInitialized) {
// sync already started for the correct db, don't start it again
return userInfo;
}
if (this.wordlistSync) {
this.wordlistSync.cancel();
this.wordlistSync = undefined;
}
if (!usersWordlistInitialized) {
initializeLocalDb("wordlist", this.handleRefreshWordlist, userInfo.name);
}
this.wordlistSync = startLocalDbSync("wordlist", { name: userInfo.name, password: userInfo.userdbPassword });
const { userTextOptionsRecord, serverOptionsAreNewer } = resolveTextOptions(userOnServer, prevUser, this.state.options.textOptionsRecord);
const user: AT.LingdocsUser = {
...userOnServer,
userTextOptionsRecord,
};
this.setState({ user });
saveUser(user);
const textOptionsRecord: TextOptionsRecord = {
lastModified: userTextOptionsRecord.lastModified,
textOptions: {
...userTextOptionsRecord.userTextOptions,
pTextSize: getTextOptions(this.state).pTextSize,
},
};
this.handleOptionsUpdate({ type: "updateTextOptionsRecord", payload: textOptionsRecord });
if (!serverOptionsAreNewer) {
updateUserTextOptionsRecord(userTextOptionsRecord).catch(console.error);
}
if (user) {
startLocalDbs(user, { wordlist: this.handleRefreshWordlist, reviewTasks: this.handleRefreshReviewTasks });
} else {
stopLocalDbs();
}
return userInfo;
} catch (err) {
console.error("error checking user level", err);
// don't downgrade the level if it's editor/studend and offline (can't check user info)
return undefined;
}
}
@ -345,6 +317,7 @@ class App extends Component<RouteComponentProps, State> {
if (action.type === "changeTheme") {
document.documentElement.setAttribute("data-theme", action.payload);
}
// TODO: use a seperate reducer for changing text options (otherwise you could just be updating the saved text options instead of the user text options that the program is going off of)
const options = optionsReducer(this.state.options, action);
saveOptions(options);
if (action.type === "toggleLanguage" || action.type === "toggleSearchType") {
@ -363,6 +336,25 @@ class App extends Component<RouteComponentProps, State> {
}
}
private handleTextOptionsUpdate(action: TextOptionsAction) {
const textOptions = textOptionsReducer(this.state.options.textOptionsRecord.textOptions, action);
const lastModified = Date.now() as AT.TimeStamp;
const textOptionsRecord: TextOptionsRecord = {
lastModified,
textOptions,
};
this.handleOptionsUpdate({ type: "updateTextOptionsRecord", payload: textOptionsRecord });
// try to save the new text options to the user
if (this.state.user) {
const userTextOptions = removePTextSize(textOptions);
const userTextOptionsRecord = {
userTextOptions,
lastModified,
};
updateUserTextOptionsRecord(userTextOptionsRecord).catch(console.error);
}
}
private handleSearchValueChange(searchValue: string) {
if (this.state.dictionaryStatus !== "ready") return;
if (searchValue === "") {
@ -401,10 +393,11 @@ class App extends Component<RouteComponentProps, State> {
}
}
private checkUserCronJob = new CronJob("1/20 * * * * *", () => {
this.handleLoadUser();
})
private networkCronJob = new CronJob("1/5 * * * *", () => {
// TODO: check for new dictionary (in a seperate cron job - not dependant on the user being signed in)
this.handleLoadUserInfo();
sendSubmissions();
this.handleDictionaryUpdate();
});
@ -445,7 +438,7 @@ class App extends Component<RouteComponentProps, State> {
paddingBottom: "60px",
}}>
<Helmet>
<title>LingDocs Pashto Dictionary</title>
<title>LingDocs Dictionary - Dev Branch</title>
</Helmet>
{this.state.options.searchBarPosition === "top" && <SearchBar
state={this.state}
@ -459,11 +452,11 @@ class App extends Component<RouteComponentProps, State> {
<>
<Route path="/" exact>
<div className="text-center mt-4">
<h4 className="font-weight-light p-3 mb-4">LingDocs Pashto Dictionary</h4>
<h4 className="font-weight-light p-3 mb-4">LingDocs Pashto Dictionary - DEV</h4>
{this.state.options.searchType === "alphabetical" && <div className="mt-4 font-weight-light">
<div className="mb-3"><span className="fa fa-book mr-2" ></span> Alphabetical browsing mode</div>
</div>}
{this.state.options.level === "editor" && <div className="mt-4 font-weight-light">
{this.state.user?.level === "editor" && <div className="mt-4 font-weight-light">
<div className="mb-3">Editor priveleges active</div>
<Link to="/edit">
<button className="btn btn-secondary">New Entry</button>
@ -478,7 +471,12 @@ class App extends Component<RouteComponentProps, State> {
<About state={this.state} />
</Route>
<Route path="/settings">
<Options options={this.state.options} optionsDispatch={this.handleOptionsUpdate} />
<Options
state={this.state}
options={this.state.options}
optionsDispatch={this.handleOptionsUpdate}
textOptionsDispatch={this.handleTextOptionsUpdate}
/>
</Route>
<Route path="/search">
<Results state={this.state} isolateEntry={this.handleIsolateEntry} />
@ -492,10 +490,7 @@ class App extends Component<RouteComponentProps, State> {
}
</Route>
<Route path="/account">
<Account level={this.state.options.level} loadUserInfo={this.handleLoadUserInfo} handleSignOut={(() => {
this.props.history.replace("/");
auth.signOut();
})} />
<Account user={this.state.user} loadUser={this.handleLoadUser} />
</Route>
<Route path="/word">
<IsolatedEntry
@ -504,21 +499,21 @@ class App extends Component<RouteComponentProps, State> {
isolateEntry={this.handleIsolateEntry}
/>
</Route>
{wordlistEnabled(this.state) && <Route path="/wordlist">
{wordlistEnabled(this.state.user) && <Route path="/wordlist">
<Wordlist
state={this.state}
isolateEntry={this.handleIsolateEntry}
optionsDispatch={this.handleOptionsUpdate}
/>
</Route>}
{this.state.options.level === "editor" && <Route path="/edit">
{this.state.user?.level === "editor" && <Route path="/edit">
<EntryEditor
state={this.state}
dictionary={dictionary}
searchParams={new URLSearchParams(this.props.history.location.search)}
/>
</Route>}
{this.state.options.level === "editor" && <Route path="/review-tasks">
{this.state.user?.level === "editor" && <Route path="/review-tasks">
<ReviewTasks state={this.state} />
</Route>}
</>
@ -534,15 +529,15 @@ class App extends Component<RouteComponentProps, State> {
<div className="buttons-footer">
<BottomNavItem label="About" icon="info-circle" page="/about" />
<BottomNavItem label="Settings" icon="cog" page="/settings" />
<BottomNavItem label={auth.currentUser ? "Account" : "Sign In"} icon="user" page="/account" />
{wordlistEnabled(this.state) &&
<BottomNavItem label={this.state.user ? "Account" : "Sign In"} icon="user" page="/account" />
{wordlistEnabled(this.state.user) &&
<BottomNavItem
label={`Wordlist ${this.state.options.wordlistReviewBadge ? textBadge(forReview(this.state.wordlist).length) : ""}`}
icon="list"
page="/wordlist"
/>
}
{this.state.options.level === "editor" &&
{this.state.user?.level === "editor" &&
<BottomNavItem
label={`Tasks ${textBadge(this.state.reviewTasks.length)}`}
icon="edit"

View File

@ -22,7 +22,7 @@ ReactDOM.render(
document.getElementById('root')
);
serviceWorkerRegistration.register();
serviceWorkerRegistration.unregister();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))

View File

@ -0,0 +1,78 @@
export type Hash = string & { __brand: "Hashed String" };
export type UUID = string & { __brand: "Random Unique UID" };
export type TimeStamp = number & { __brand: "UNIX Timestamp in milliseconds" };
export type UserDbPassword = string & { __brand: "password for an individual user couchdb" };
export type WordlistDbName = string & { __brand: "name for an individual user couchdb" };
export type URLToken = string & { __brand: "Base 64 URL Token" };
export type EmailVerified = true | Hash | false;
export type ActionComplete = { ok: true, message: string };
export type ActionError = { ok: false, error: string };
export type APIResponse = ActionComplete | ActionError | { ok: true, user: LingdocsUser };
export type WoutRJ<T> = Omit<T, "_raw"|"_json">;
export type GoogleProfile = WoutRJ<import("passport-google-oauth").Profile> & { refreshToken: string, accessToken: string };
export type GitHubProfile = WoutRJ<import("passport-github2").Profile> & { accessToken: string };
export type TwitterProfile = WoutRJ<import("passport-twitter").Profile> & { token: string, tokenSecret: string };
export type ProviderProfile = GoogleProfile | GitHubProfile | TwitterProfile;
export type UserLevel = "basic" | "student" | "editor";
export type UserTextOptions = Omit<import("@lingdocs/pashto-inflector").Types.TextOptions, "pTextSize">;
export type UserTextOptionsRecord = {
lastModified: TimeStamp,
userTextOptions: UserTextOptions,
};
// TODO: TYPE GUARDING SO WE NEVER HAVE A USER WITH NO Id or Password
export type LingdocsUser = {
userId: UUID,
admin?: boolean,
password?: Hash,
name: string,
email?: string,
emailVerified: EmailVerified,
github?: GitHubProfile,
google?: GoogleProfile,
twitter?: TwitterProfile,
passwordReset?: {
tokenHash: Hash,
requestedOn: TimeStamp,
},
upgradeToStudentRequest?: "waiting" | "denied",
tests: [],
lastLogin: TimeStamp,
lastActive: TimeStamp,
userTextOptionsRecord: undefined | UserTextOptionsRecord,
} & (
{ level: "basic" } |
{
level: "student" | "editor",
couchDbPassword: UserDbPassword,
wordlistDbName: WordlistDbName,
}
) & import("nano").MaybeDocument;
export type CouchDbAuthUser = {
type: "user",
name: UUID,
password: UserDbPassword,
roles: [],
} & import("nano").MaybeDocument;
export type UpgradeUserResponse = {
ok: false,
error: "incorrect password",
} | {
ok: true,
message: "user already upgraded" | "user upgraded to student",
user: LingdocsUser,
};
export type UpdateUserTextOptionsRecordBody = { userTextOptionsRecord: UserTextOptionsRecord };
export type UpdateUserTextOptionsRecordResponse = {
ok: true,
message: "updated userTextOptionsRecord",
user: LingdocsUser,
};

View File

@ -1,56 +1,73 @@
import { auth } from "./firebase";
import * as BT from "./backend-types";
import * as FT from "./functions-types";
import * as AT from "./account-types";
const functionsBaseUrl = // process.env.NODE_ENV === "development"
// "http://127.0.0.1:5001/lingdocs/europe-west1/"
"https://europe-west1-lingdocs.cloudfunctions.net/";
type Service = "account" | "functions";
const baseUrl: Record<Service, string> = {
account: "https://account.lingdocs.com/api/",
functions: "https://functions.lingdocs.com/",
};
export async function publishDictionary(): Promise<BT.PublishDictionaryResponse> {
const res = await tokenFetch("publishDictionary");
if (!res) {
throw new Error("Connection error/offline");
}
return res;
// FUNCTIONS CALLS - MUST BE RE-ROUTED THROUGH FIREBASE HOSTING IN ../../../firebase.json
export async function publishDictionary(): Promise<FT.PublishDictionaryResponse | FT.FunctionError> {
return await myFetch("functions", "publishDictionary") as FT.PublishDictionaryResponse | FT.FunctionError;
}
export async function upgradeAccount(password: string): Promise<BT.UpgradeUserResponse> {
const res = await tokenFetch("upgradeUser", "POST", { password });
if (!res) {
throw new Error("Connection error/offline");
}
return res;
export async function postSubmissions(submissions: FT.SubmissionsRequest): Promise<FT.SubmissionsResponse> {
return await myFetch("functions", "submissions", "POST", submissions) as FT.SubmissionsResponse;
}
export async function postSubmissions(submissions: BT.SubmissionsRequest): Promise<BT.SubmissionsResponse> {
return await tokenFetch("submissions", "POST", submissions) as BT.SubmissionsResponse;
// ACCOUNT CALLS
export async function upgradeAccount(password: string): Promise<AT.UpgradeUserResponse> {
const response = await myFetch("account", "user/upgrade", "PUT", { password });
return response as AT.UpgradeUserResponse;
}
export async function loadUserInfo(): Promise<undefined | BT.CouchDbUser> {
const res = await tokenFetch("getUserInfo", "GET") as BT.GetUserInfoResponse;
return "user" in res ? res.user : undefined;
export async function upgradeToStudentRequest(): Promise<AT.APIResponse> {
return await myFetch("account", "user/upgradeToStudentRequest", "POST") as AT.APIResponse;
}
// TODO: HARD TYPING OF THIS WITH THE subUrl and return values etc?
async function tokenFetch(subUrl: string, method?: "GET" | "POST", body?: any): Promise<any> {
if (!auth.currentUser) {
throw new Error("not signed in");
}
export async function updateUserTextOptionsRecord(userTextOptionsRecord: AT.UserTextOptionsRecord): Promise<AT.UpdateUserTextOptionsRecordResponse> {
const response = await myFetch("account", "user/userTextOptionsRecord", "PUT", { userTextOptionsRecord }) as AT.UpdateUserTextOptionsRecordResponse;
return response;
}
export async function signOut() {
try {
const token = await auth.currentUser.getIdToken();
const response = await fetch(`${functionsBaseUrl}${subUrl}`, {
method,
await myFetch("account", "sign-out", "POST");
} catch (e) {
return;
}
}
export async function getUser(): Promise<undefined | AT.LingdocsUser | "offline"> {
try {
const response = await myFetch("account", "user");
if ("user" in response) {
return response.user;
}
return undefined;
} catch (e) {
console.error(e);
return "offline";
}
}
async function myFetch(
service: Service,
url: string,
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
body?: FT.SubmissionsRequest | { password: string } | AT.UpdateUserTextOptionsRecordBody,
): Promise<AT.APIResponse> {
const response = await fetch(baseUrl[service] + url, {
method,
credentials: "include",
...body ? {
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
...body ? {
body: JSON.stringify(body),
} : {},
});
return await response.json();
} catch (err) {
console.error(err);
throw err;
}
body: JSON.stringify(body),
} : {},
});
return await response.json() as AT.APIResponse;
}

View File

@ -19,6 +19,7 @@ import { fuzzifyPashto } from "./fuzzify-pashto/fuzzify-pashto";
// @ts-ignore
import relevancy from "relevancy";
import { makeAWeeBitFuzzy } from "./wee-bit-fuzzy";
import { getTextOptions } from "./get-text-options";
// const dictionaryBaseUrl = "https://storage.googleapis.com/lingdocs/";
const dictionaryUrl = `https://storage.googleapis.com/lingdocs/dictionary`;
@ -353,7 +354,7 @@ export const dictionary: DictionaryAPI = {
search: function(state: State): Types.DictionaryEntry[] {
const searchString = convertSpelling(
state.searchValue,
state.options.textOptions.spelling,
getTextOptions(state).spelling,
);
if (state.searchValue === "") {
return [];

View File

@ -1,30 +0,0 @@
import firebase from "firebase/app";
import "firebase/auth";
// Configure Firebase.
const config = {
apiKey: "AIzaSyDZrG2BpQi0MGktEKXL6mIWeAYEn_gFacw",
authDomain: "lingdocs.firebaseapp.com",
projectId: "lingdocs",
};
firebase.initializeApp(config);
export const authUiConfig = {
// Popup signin flow rather than redirect flow.
signInFlow: "popup",
signInOptions: [
firebase.auth.EmailAuthProvider.PROVIDER_ID,
firebase.auth.GithubAuthProvider.PROVIDER_ID,
// twitter auth is set up, but not using because it doesn't provide an email
// firebase.auth.TwitterAuthProvider.PROVIDER_ID,
// firebase.auth.GoogleAuthProvider.PROVIDER_ID,
],
callbacks: {
// Avoid redirects after sign-in.
signInSuccessWithAuthResult: () => false,
},
};
export const auth = firebase.auth();

View File

@ -7,6 +7,11 @@
*/
import { Types as T } from "@lingdocs/pashto-inflector";
import * as AT from "./account-types";
export type FunctionResponse = PublishDictionaryResponse | SubmissionsResponse | FunctionError;
export type FunctionError = { ok: false, error: string };
export type PublishDictionaryResponse = {
ok: true,
@ -16,20 +21,18 @@ export type PublishDictionaryResponse = {
errors: T.DictionaryEntryError[],
};
export type UserInfo = {
uid: string,
email: string | null,
displayName: string | null,
}
export type Submission = Edit | ReviewTask;
export type Edit = EntryEdit | NewEntry | EntryDeletion
export type SubmissionBase = {
sTs: number,
user: UserInfo,
_id: string,
sTs: number,
user: {
userId: AT.UUID,
name: string,
email: string,
},
}
export type ReviewTask = Issue | EditSuggestion | EntrySuggestion;
@ -73,36 +76,3 @@ export type SubmissionsResponse = {
message: string,
submissions: Submission[],
};
export type UserLevel = "basic" | "student" | "editor";
export type CouchDbUser = {
_id: string,
type: "user",
_rev?: string,
name: string,
email: string,
providerData: any,
displayName: string,
roles: [],
password?: string,
level: UserLevel,
userdbPassword: string,
}
export type GetUserInfoResponse = {
ok: true,
message: "no couchdb user found",
} | {
ok: true,
user: CouchDbUser,
}
export type UpgradeUserResponse = {
ok: false,
error: "incorrect password",
} | {
ok: true,
message: "user already upgraded" | "user upgraded to student",
};

View File

@ -0,0 +1,5 @@
import { Types as T } from "@lingdocs/pashto-inflector";
export function getTextOptions(state: State): T.TextOptions {
return state.options.textOptionsRecord.textOptions;
}

View File

@ -1,14 +1,6 @@
/**
* Copyright (c) 2021 lingdocs.com
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type { LingdocsUser } from "./account-types";
export function wordlistEnabled(state: State | UserLevel): boolean {
const level = (typeof state === "string")
? state
: state.options.level;
return level !== "basic";
}
export function wordlistEnabled(user: LingdocsUser | undefined): boolean {
if (!user) return false;
return user.level !== "basic";
}

View File

@ -6,7 +6,7 @@
*
*/
import { saveOptions, readOptions, optionsLocalStorageName } from "./options-storage";
import { saveOptions, readOptions, optionsLocalStorageName } from "./local-storage";
import {
defaultTextOptions,
} from "@lingdocs/pashto-inflector";
@ -16,7 +16,6 @@ const optionsStub: Options = {
searchType: "fuzzy",
theme: "dark",
textOptions: defaultTextOptions,
level: "student",
wordlistMode: "browse",
wordlistReviewLanguage: "Pashto",
wordlistReviewBadge: true,

View File

@ -0,0 +1,52 @@
/**
* Copyright (c) 2021 lingdocs.com
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import * as AT from "./account-types";
export const optionsLocalStorageName = "options3";
export const userLocalStorageName = "user1";
export function saveOptions(options: Options): void {
localStorage.setItem(optionsLocalStorageName, JSON.stringify(options));
};
export const readOptions = (): undefined | Options => {
const optionsRaw = localStorage.getItem(optionsLocalStorageName);
if (!optionsRaw) {
return undefined;
}
try {
const options = JSON.parse(optionsRaw) as Options;
return options;
} catch (e) {
console.error("error parsing saved state JSON", e);
return undefined;
}
};
export function saveUser(user: AT.LingdocsUser | undefined): void {
if (user) {
localStorage.setItem(userLocalStorageName, JSON.stringify(user));
} else {
localStorage.removeItem(userLocalStorageName);
}
};
export const readUser = (): AT.LingdocsUser | undefined => {
const userRaw = localStorage.getItem(userLocalStorageName);
if (!userRaw) {
return undefined;
}
try {
const user = JSON.parse(userRaw) as AT.LingdocsUser;
return user;
} catch (e) {
console.error("error parsing saved user JSON", e);
return undefined;
}
};

View File

@ -1,105 +0,0 @@
import optionsReducer from "./options-reducer";
import { defaultTextOptions } from "@lingdocs/pashto-inflector";
const options: Options = {
textOptions: defaultTextOptions,
language: "Pashto",
searchType: "fuzzy",
theme: "light",
level: "basic",
wordlistMode: "browse",
wordlistReviewLanguage: "Pashto",
wordlistReviewBadge: true,
searchBarPosition: "top",
};
test("options reducer should work", () => {
expect(optionsReducer(options, { type: "toggleLanguage" }))
.toEqual({
...options,
language: "English",
});
expect(optionsReducer({ ...options, language: "English" }, { type: "toggleLanguage" }))
.toEqual(options);
expect(optionsReducer(options, { type: "toggleSearchType" }))
.toEqual({
...options,
searchType: "alphabetical",
});
expect(optionsReducer({ ...options, searchType: "alphabetical" }, { type: "toggleSearchType" }))
.toEqual(options);
expect(optionsReducer(options, { type: "changeTheme", payload: "dark" }))
.toEqual({
...options,
theme: "dark",
});
expect(optionsReducer(options, { type: "changeUserLevel", payload: "student" }))
.toEqual({
...options,
level: "student",
});
expect(optionsReducer(options, { type: "changeWordlistMode", payload: "review" }))
.toEqual({
...options,
wordlistMode: "review",
});
expect(optionsReducer(options, { type: "changeWordlistReviewLanguage", payload: "English" }))
.toEqual({
...options,
wordlistReviewLanguage: "English",
});
expect(optionsReducer(options, { type: "changeWordlistReviewBadge", payload: false }))
.toEqual({
...options,
wordlistReviewBadge: false,
});
expect(optionsReducer(options, { type: "changeSearchBarPosition", payload: "bottom" }))
.toEqual({
...options,
searchBarPosition: "bottom",
});
expect(optionsReducer(options, { type: "changePTextSize", payload: "largest" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
pTextSize: "largest",
},
});
expect(optionsReducer(options, { type: "changeSpelling", payload: "Pakistani" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
spelling: "Pakistani",
},
});
expect(optionsReducer(options, { type: "changePhonetics", payload: "ipa" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
phonetics: "ipa",
},
});
expect(optionsReducer(options, { type: "changeDialect", payload: "southern" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
dialect: "southern",
},
});
expect(optionsReducer(options, { type: "changeDiacritics", payload: true }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
diacritics: true,
},
});
expect(() => {
// @ts-ignore
optionsReducer(options, { type: "non existent action" });
}).toThrow("action type not recognized in reducer");
})

View File

@ -1,4 +1,7 @@
function optionsReducer(options: Options, action: OptionsAction): Options {
import { Types as IT } from "@lingdocs/pashto-inflector";
import * as AT from "./account-types";
export function optionsReducer(options: Options, action: OptionsAction): Options {
if (action.type === "toggleLanguage") {
return {
...options,
@ -23,12 +26,6 @@ function optionsReducer(options: Options, action: OptionsAction): Options {
searchBarPosition: action.payload,
};
}
if (action.type === "changeUserLevel") {
return {
...options,
level: action.payload,
};
}
if (action.type === "changeWordlistMode") {
return {
...options,
@ -47,52 +44,81 @@ function optionsReducer(options: Options, action: OptionsAction): Options {
wordlistReviewLanguage: action.payload,
};
}
if (action.type === "changePTextSize") {
if (action.type === "updateTextOptionsRecord") {
return {
...options,
textOptions: {
...options.textOptions,
pTextSize: action.payload,
},
textOptionsRecord: action.payload,
};
}
if (action.type === "changeSpelling") {
return {
...options,
textOptions: {
...options.textOptions,
spelling: action.payload,
}
};
}
if (action.type === "changePhonetics") {
return {
...options,
textOptions: {
...options.textOptions,
phonetics: action.payload,
}
};
}
if (action.type === "changeDialect") {
return {
...options,
textOptions: {
...options.textOptions,
dialect: action.payload,
}
};
}
if (action.type === "changeDiacritics") {
return {
...options,
textOptions: {
...options.textOptions,
diacritics: action.payload,
}
};
}
throw new Error("action type not recognized in reducer");
}
throw new Error("action type not recognized in options reducer");
}
export default optionsReducer;
export function textOptionsReducer(textOptions: IT.TextOptions, action: TextOptionsAction): IT.TextOptions {
if (action.type === "changePTextSize") {
return {
...textOptions,
pTextSize: action.payload,
};
}
if (action.type === "changeSpelling") {
return {
...textOptions,
spelling: action.payload,
};
}
if (action.type === "changePhonetics") {
return {
...textOptions,
phonetics: action.payload,
};
}
if (action.type === "changeDialect") {
return {
...textOptions,
dialect: action.payload,
};
}
if (action.type === "changeDiacritics") {
return {
...textOptions,
diacritics: action.payload,
};
}
throw new Error("action type not recognized in text options reducer");
}
export function removePTextSize(textOptions: IT.TextOptions): AT.UserTextOptions {
const { pTextSize, ...userTextOptions } = textOptions;
return userTextOptions;
}
export function resolveTextOptions(userOnServer: AT.LingdocsUser, prevUser: AT.LingdocsUser | undefined, localTextOptionsRecord: TextOptionsRecord): { userTextOptionsRecord: AT.UserTextOptionsRecord, serverOptionsAreNewer: boolean } {
const isANewUser = !prevUser || (userOnServer.userId !== prevUser.userId);
if (isANewUser) {
// take the new user's text options, if the have any
// if not just take the equivalent of the user text options from the saved record
return userOnServer.userTextOptionsRecord
? {
serverOptionsAreNewer: true,
userTextOptionsRecord: userOnServer.userTextOptionsRecord,
}
: {
serverOptionsAreNewer: false,
userTextOptionsRecord: {
lastModified: localTextOptionsRecord.lastModified,
userTextOptions: removePTextSize(localTextOptionsRecord.textOptions),
}
};
}
// if the new user is the same as the existing user that we had
const serverOptionsAreNewer = !!(userOnServer.userTextOptionsRecord && (userOnServer.userTextOptionsRecord.lastModified > localTextOptionsRecord.lastModified));
return {
serverOptionsAreNewer,
userTextOptionsRecord: (serverOptionsAreNewer && userOnServer.userTextOptionsRecord)
? userOnServer.userTextOptionsRecord
: {
lastModified: localTextOptionsRecord.lastModified,
userTextOptions: removePTextSize(localTextOptionsRecord.textOptions),
},
};
}

View File

@ -1,34 +0,0 @@
/**
* Copyright (c) 2021 lingdocs.com
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
export const optionsLocalStorageName = "options2";
export function saveOptions(options: Options): void {
localStorage.setItem(optionsLocalStorageName, JSON.stringify(options));
};
export const readOptions = (): Options | undefined => {
const optionsRaw = localStorage.getItem(optionsLocalStorageName);
if (!optionsRaw) {
return undefined;
}
try {
const options = JSON.parse(optionsRaw) as Options;
// check for new options here
if (options.wordlistReviewBadge === undefined) {
options.wordlistReviewBadge = true;
}
if (options.searchBarPosition === undefined) {
options.searchBarPosition = "top";
}
return options;
} catch (e) {
console.error("error parsing saved state JSON", e);
return undefined;
}
};

View File

@ -1,75 +1,115 @@
import PouchDB from "pouchdb";
import * as BT from "./backend-types";
import * as AT from "./account-types";
import * as FT from "./functions-types";
type LocalDbType = "submissions" | "wordlist" | "reviewTasks";
type LocalDb = null | { refresh: () => void, db: PouchDB.Database };
const localDbTypes: LocalDbType[] = ["submissions", "wordlist", "reviewTasks"];
type UnsyncedLocalDb = {
refresh: () => void,
db: PouchDB.Database,
};
type SyncedLocalDb = UnsyncedLocalDb & {
sync: PouchDB.Replication.Sync<any>,
};
type DBS = {
submissions: undefined | UnsyncedLocalDb,
wordlist: undefined | SyncedLocalDb,
reviewTasks: undefined | SyncedLocalDb,
};
type DbInput = {
type: "wordlist",
doc: WordlistWord,
} | {
type: "submissions",
doc: BT.Submission,
doc: FT.Submission,
} | {
type: "reviewTasks",
doc: BT.ReviewTask,
doc: FT.ReviewTask,
};
const dbs: Record<LocalDbType, LocalDb> = {
const dbs: DBS = {
/* for anyone logged in - for edits/suggestions submissions */
submissions: null,
submissions: undefined,
/* for students and above - personal wordlist database */
wordlist: null,
wordlist: undefined,
/* for editors only - edits/suggestions (submissions) for review */
reviewTasks: null,
reviewTasks: undefined,
};
export function initializeLocalDb(type: LocalDbType, refresh: () => void, uid?: string | undefined) {
const name = type === "wordlist"
? `userdb-${uid? stringToHex(uid) : "guest"}`
export function startLocalDbs(user: AT.LingdocsUser, refreshFns: { wordlist: () => void, reviewTasks: () => void }) {
if (user.level === "basic") {
initializeLocalDb("submissions", () => null, user);
}
if (user.level === "student") {
initializeLocalDb("submissions", () => null, user);
initializeLocalDb("wordlist", refreshFns.wordlist, user);
}
if (user.level === "editor") {
deInitializeLocalDb("submissions");
initializeLocalDb("reviewTasks", refreshFns.reviewTasks, user);
initializeLocalDb("wordlist", refreshFns.wordlist, user);
}
}
function deInitializeLocalDb(type: LocalDbType) {
const db = dbs[type];
if (db && "sync" in db) {
db.sync.cancel();
}
dbs[type] = undefined;
}
export function stopLocalDbs() {
localDbTypes.forEach((type) => {
deInitializeLocalDb(type);
});
}
function initializeLocalDb(type: LocalDbType, refresh: () => void, user: AT.LingdocsUser) {
if (type !== "submissions" && "wordlistDb" in user) return
const name = type === "reviewTasks"
? "review-tasks"
: type === "submissions"
? "submissions"
: "review-tasks";
: (type === "wordlist" && "wordlistDbName" in user)
? user.wordlistDbName
: "";
const password = "couchDbPassword" in user ? user.couchDbPassword : "";
const db = dbs[type];
// only initialize the db if it doesn't exist or if it has a different name
if ((!db) || (db.db?.name !== name)) {
dbs[type] = {
db: new PouchDB(name),
refresh,
};
if (type === "submissions") {
dbs[type] = {
refresh,
db: new PouchDB(name),
};
} else {
dbs[type]?.sync.cancel();
const db = new PouchDB(name);
dbs[type] = {
db,
refresh,
sync: db.sync(
`https://${user.userId}:${password}@couch.lingdocs.com/${name}`,
{ live: true, retry: true },
).on("change", (info) => {
if (info.direction === "pull") {
refresh();
}
}).on("error", (error) => {
console.error(error);
}),
};
}
refresh();
}
}
export function getLocalDbName(type: LocalDbType) {
return dbs[type]?.db.name;
}
export function deInitializeLocalDb(type: LocalDbType) {
dbs[type] = null;
}
export function startLocalDbSync(
type: "wordlist" | "reviewTasks",
auth: { name: string, password: string },
) {
const localDb = dbs[type];
if (!localDb) {
console.error(`unable to start sync because ${type} database is not initialized`);
return;
}
const sync = localDb.db.sync(
`https://${auth.name}:${auth.password}@couchdb.lingdocs.com/${localDb.db.name}`,
{ live: true, retry: true },
).on("change", (info) => {
if (info.direction === "pull") {
localDb.refresh();
}
}).on("error", (error) => {
console.error(error);
});
return sync;
}
export async function addToLocalDb({ type, doc }: DbInput) {
const localDb = dbs[type];
if (!localDb) {
@ -99,13 +139,13 @@ export async function updateLocalDbDoc({ type, doc }: DbInput, id: string) {
return updated;
}
export async function getAllDocsLocalDb(type: "submissions", limit?: number): Promise<BT.Submission[]>;
export async function getAllDocsLocalDb(type: "submissions", limit?: number): Promise<FT.Submission[]>;
export async function getAllDocsLocalDb(type: "wordlist", limit?: number): Promise<WordlistWordDoc[]>;
export async function getAllDocsLocalDb(type: "reviewTasks", limit?: number): Promise<BT.ReviewTask[]>
export async function getAllDocsLocalDb(type: LocalDbType, limit?: number): Promise<BT.Submission[] | WordlistWordDoc[] | BT.ReviewTask[]> {
export async function getAllDocsLocalDb(type: "reviewTasks", limit?: number): Promise<FT.ReviewTask[]>
export async function getAllDocsLocalDb(type: LocalDbType, limit?: number): Promise<FT.Submission[] | WordlistWordDoc[] | FT.ReviewTask[]> {
const localDb = dbs[type];
if (!localDb) {
throw new Error(`unable to get all docs from ${type} database - not initialized`);
return [];
}
const descending = type !== "reviewTasks";
const result = await localDb.db.allDocs({
@ -116,11 +156,11 @@ export async function getAllDocsLocalDb(type: LocalDbType, limit?: number): Prom
const docs = result.rows.map((row) => row.doc) as unknown;
switch (type) {
case "submissions":
return docs as BT.Submission[];
return docs as FT.Submission[];
case "wordlist":
return docs as WordlistWordDoc[];
case "reviewTasks":
return docs as BT.ReviewTask[];
return docs as FT.ReviewTask[];
}
}
@ -158,13 +198,4 @@ export async function deleteFromLocalDb(type: LocalDbType, id: string | string[]
await localDb.db.remove(doc);
}
localDb.refresh();
}
function stringToHex(str: string) {
const arr1 = [];
for (let n = 0, l = str.length; n < l; n ++) {
const hex = Number(str.charCodeAt(n)).toString(16);
arr1.push(hex);
}
return arr1.join('');
}
}

View File

@ -28,7 +28,7 @@ function fFuzzy(f: string): string {
}
export function searchAllInflections(allDocs: T.DictionaryEntry[], searchValue: string): { entry: T.DictionaryEntry, results: InflectionSearchResult[] }[] {
const timerLabel = "Search inflections";
// const timerLabel = "Search inflections";
const beg = fFuzzy(searchValue.slice(0, 2));
const preSearchFun = isPashtoScript(searchValue)
? (ps: T.PsString) => ps.p.slice(0, 2) === beg
@ -37,7 +37,7 @@ export function searchAllInflections(allDocs: T.DictionaryEntry[], searchValue:
const searchFun = isPashtoScript(searchValue)
? (ps: T.PsString) => ps.p === searchValue
: (ps: T.PsString) => !!ps.f.match(fRegex);
console.time(timerLabel);
// console.time(timerLabel);
const results = allDocs.reduce((all: { entry: T.DictionaryEntry, results: InflectionSearchResult[] }[], entry) => {
const type = isNounAdjOrVerb(entry);
if (entry.c && type === "verb") {
@ -74,6 +74,6 @@ export function searchAllInflections(allDocs: T.DictionaryEntry[], searchValue:
}
return all;
}, []);
console.timeEnd(timerLabel);
// console.timeEnd(timerLabel);
return results;
}

View File

@ -1,28 +1,22 @@
import * as BT from "./backend-types";
import { auth } from "./firebase";
import * as FT from "./functions-types";
import * as AT from "./account-types";
import {
postSubmissions,
} from "./backend-calls";
import {
initializeLocalDb,
addToLocalDb,
getAllDocsLocalDb,
deleteFromLocalDb,
} from "./pouch-dbs";
initializeLocalDb("submissions", () => null);
export function submissionBase(): BT.SubmissionBase {
if (!auth.currentUser) {
throw new Error("not signed in");
}
export function submissionBase(user: AT.LingdocsUser): FT.SubmissionBase {
return {
sTs: Date.now(),
_id: new Date().toJSON(),
user: {
uid: auth.currentUser.uid,
email: auth.currentUser.email,
displayName: auth.currentUser.displayName,
name: user.name,
email: user.email || "",
userId: user.userId,
},
};
}
@ -40,16 +34,19 @@ export async function sendSubmissions() {
}));
const res = await postSubmissions(revRemoved);
// delete the submissions that were received from the local submissions db
res.submissions.forEach((submission) => {
deleteFromLocalDb("submissions", submission._id);
});
console.log(res);
if (res.submissions) {
res.submissions.forEach((submission) => {
deleteFromLocalDb("submissions", submission._id);
});
}
} catch (err) {
console.error("error posting submissions", err);
}
}
export async function addSubmission(submission: BT.Submission, level: BT.UserLevel) {
if (level === "editor" && (submission.type === "issue" || submission.type === "entry suggestion" || submission.type === "edit suggestion")) {
export async function addSubmission(submission: FT.Submission, user: AT.LingdocsUser) {
if (user.level === "editor" && (submission.type === "issue" || submission.type === "entry suggestion" || submission.type === "edit suggestion")) {
await addToLocalDb({ type: "reviewTasks", doc: submission })
} else {
await addToLocalDb({ type: "submissions", doc: submission });

View File

@ -1,72 +1,74 @@
import { useState, useEffect } from "react";
import {
useState,
useEffect,
} from "react";
import { Modal, Button } from "react-bootstrap";
import { Link } from "react-router-dom";
import { auth, authUiConfig } from "../lib/firebase";
import StyledFirebaseAuth from "react-firebaseui/StyledFirebaseAuth";
import {
upgradeAccount,
signOut,
publishDictionary,
upgradeToStudentRequest,
} from "../lib/backend-calls";
import LoadingElipses from "../components/LoadingElipses";
import { Helmet } from "react-helmet";
import * as AT from "../lib/account-types";
const providers: ("google" | "twitter" | "github")[] = ["google", "twitter", "github"];
const capitalize = (s: string): string => {
// if (!s) return "";
return s.charAt(0).toUpperCase() + s.slice(1);
}
const Account = ({ handleSignOut, level, loadUserInfo }: {
handleSignOut: () => void,
loadUserInfo: () => void,
level: UserLevel,
}) => {
const [showingDeleteConfirmation, setShowingDeleteConfirmation] = useState<boolean>(false);
let popupRef: Window | null = null;
const Account = ({ user, loadUser }: { user: AT.LingdocsUser | undefined, loadUser: () => void }) => {
const [showingUpgradePrompt, setShowingUpgradePrompt] = useState<boolean>(false);
const [upgradePassword, setUpgradePassword] = useState<string>("");
const [upgradeError, setUpgradeError] = useState<string>("");
const [accountDeleted, setAccountDeleted] = useState<boolean>(false);
const [accountDeleteError, setAccountDeleteError] = useState<string>("");
const [emailVerification, setEmailVerification] = useState<"unverified" | "sent" | "verified">("verified");
const [waiting, setWaiting] = useState<boolean>(false);
const [publishingStatus, setPublishingStatus] = useState<undefined | "publishing" | any>(undefined);
const [showingPasswordChange, setShowingPasswordChange] = useState<boolean>(false);
const [password, setPassword] = useState<string>("");
const [passwordConfirmed, setPasswordConfirmed] = useState<string>("");
const [passwordError, setPasswordError] = useState<string>("");
const [showingUpdateEmail, setShowingUpdateEmail] = useState<boolean>(false);
const [updateEmailError, setUpdateEmailError] = useState<string>("");
const [newEmail, setNewEmail] = useState<string>("");
const user = auth.currentUser;
const hasPasswordProvider = user?.providerData?.some((d) => d?.providerId === "password");
useEffect(() => {
setShowingDeleteConfirmation(false);
setShowingUpgradePrompt(false);
setUpgradePassword("");
setUpgradeError("");
setWaiting(false);
window.addEventListener("message", handleIncomingMessage);
return () => {
window.removeEventListener("message", handleIncomingMessage);
};
// eslint-disable-next-line
}, []);
useEffect(() => {
setEmailVerification((user && user.emailVerified) ? "verified" : "unverified");
}, [user]);
function handleDelete() {
auth.currentUser?.delete().then(() => {
setAccountDeleteError("");
setShowingDeleteConfirmation(false);
setAccountDeleted(true);
}).catch((err) => {
console.error(err);
setAccountDeleteError(err.message);
});
// TODO put the account url in an imported constant
function handleIncomingMessage(event: MessageEvent<any>) {
if (event.origin === "https://account.lingdocs.com" && event.data === "signed in" && popupRef) {
loadUser();
popupRef.close();
}
}
async function handleSignOut() {
await signOut();
loadUser();
}
function closeUpgrade() {
setShowingUpgradePrompt(false);
setUpgradePassword("");
setUpgradeError("");
}
function closeUpdateEmail() {
setShowingUpdateEmail(false);
setNewEmail("");
setUpdateEmailError("");
async function handleUpgradeRequest() {
setUpgradeError("");
setWaiting(true);
upgradeToStudentRequest().then((res) => {
setWaiting(false);
if (res.ok) {
loadUser();
closeUpgrade();
} else {
setUpgradeError("Error requesting upgrade");
}
}).catch((err) => {
setWaiting(false);
setUpgradeError(err.message);
});
}
async function handleUpgrade() {
setUpgradeError("");
@ -74,7 +76,7 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
upgradeAccount(upgradePassword).then((res) => {
setWaiting(false);
if (res.ok) {
loadUserInfo();
loadUser();
closeUpgrade();
} else {
setUpgradeError("Incorrect password");
@ -84,6 +86,9 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
setUpgradeError(err.message);
});
}
function handleOpenSignup() {
popupRef = window.open("https://account.lingdocs.com", "account", "height=800,width=500,top=50,left=400");
}
function handlePublish() {
setPublishingStatus("publishing");
publishDictionary().then((response) => {
@ -93,56 +98,6 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
setPublishingStatus("Offline or connection error");
});
}
function handleVerifyEmail() {
if (!user) return;
user.sendEmailVerification();
setEmailVerification("sent");
}
function handleUpdateEmail() {
if (!user) return;
user.updateEmail(newEmail).then(() => {
setShowingUpdateEmail(false);
}).catch((err) => {
setUpdateEmailError(err.message);
});
}
function closePasswordChange() {
setShowingPasswordChange(false);
setPassword("");
setPasswordConfirmed("");
}
function handlePasswordChange() {
if (!user) return;
if (password === "") {
setPasswordError("Please enter a password");
return;
}
if (password !== passwordConfirmed) {
setPasswordError("Your passwords do not match");
return;
}
user.updatePassword(password).then(() => {
closePasswordChange();
}).catch((err) => {
setPasswordError(err.message);
});
}
if (accountDeleted) {
return <div style={{ maxWidth: "30rem"}}>
<Helmet>
<link rel="canonical" href="https://dictionary.lingdocs.com/account" />
<title>Account Deleted - LingDocs Pashto Dictionary</title>
</Helmet>
<div className="alert alert-info my-4" role="alert">
<h4>Your account has been deleted 🙋</h4>
</div>
<Link to="/">
<button className="btn btn-outline-secondary">
<i className="fa fa-sign-out-alt"></i> Home
</button>
</Link>
</div>
}
if (!user) {
return <div className="text-center mt-3">
<Helmet>
@ -150,33 +105,23 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
<meta name="description" content="Sign in to the LingDocs Pashto Dictionary" />
<title>Sign In - LingDocs Pashto Dictionary</title>
</Helmet>
<h4 className="mb-4">Sign in to be able to suggest words/edits</h4>
<p style={{ margin: "0 auto", maxWidth: "500px"}}><strong>For people who previously signed in with Google.</strong> Sorry, there is a problem now and you can't get to your previous account! 😬 Don't worry, all your info is safe and it will be restored in the near future. Stay tuned.</p>
<StyledFirebaseAuth uiConfig={authUiConfig}
// callbacks: {
// not using this now because of the doubling down on user email verification
// signInSuccessWithAuthResult: (res: any) => {
// const newUser = res.additionalUserInfo?.isNewUser;
// const emailVerified = res.user.emailVerified;
// if (newUser && !emailVerified) {
// res.user.sendEmailVerification();
// setEmailVerification("sent");
// }
// return false;
// }}
firebaseAuth={auth} />
</div>;
<h2 className="my-4">Sign in to LingDocs</h2>
<p className="lead mb-4">When you sign in or make a LingDocs account you can:</p>
<div className="mb-3"><i className="fas fa-pen mr-2" /> contribute by suggesting corrections and new words</div>
<div className="mb-3"><i className="fas fa-star mr-2" /> upgrade your account and start collecting a personal <strong>wordlist</strong></div>
<div className="mb-3"><i className="fas fa-sync mr-2" /> sync your script preferences across devices</div>
<button className="btn btn-lg btn-primary my-4" onClick={handleOpenSignup}><i className="fas fa-sign-in-alt mr-2" /> Sign In</button>
</div>
}
const defaultProviderId = user.providerData[0]?.providerId;
return (
<div style={{ marginBottom: "100px" }}>
<div style={{ marginBottom: "100px", maxWidth: "40rem" }}>
<Helmet>
<link rel="canonical" href="https://dictionary.lingdocs.com/account" />
<meta name="description" content="Account for the LingDocs Pashto Dictionary" />
<title>Account - LingDocs Pashto Dictionary</title>
</Helmet>
<h2 className="mb-4">Account</h2>
{level === "editor" &&
{user.level === "editor" &&
<div className="mb-3">
<h4>Editor Tools</h4>
{publishingStatus !== "publishing" &&
@ -196,103 +141,65 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
}
</div>
}
<div style={{ maxWidth: "35rem" }}>
{user.photoURL && <div className="mb-4 mt-3" style={{ textAlign: "center" }}>
<div>
{/* {user.p && <div className="mb-4 mt-3" style={{ textAlign: "center" }}>
<img src={user.photoURL} data-testid="userAvatar" alt="avatar" style={{ borderRadius: "50%", width: "5rem", height: "5rem" }}/>
</div>}
</div>} */}
<div className="card mb-4">
<ul className="list-group list-group-flush">
<li className="list-group-item">Name: {user.displayName}</li>
<li className="list-group-item">
{user.email && <div className="d-flex justify-content-between align-items-center">
<li className="list-group-item">Name: {user.name}</li>
{user.email && <li className="list-group-item">
<div className="d-flex justify-content-between align-items-center">
<div>
<div>Email: {user.email}
{emailVerification === "unverified" && <button type="button" onClick={handleVerifyEmail} className="ml-3 btn btn-sm btn-primary">
Verify Email
</button>}
</div>
{emailVerification === "unverified" && <div className="mt-2" style={{ color: "red" }}>
Please Verify Your Email Address
</div>}
{emailVerification === "sent" && <div className="mt-2">
📧 Check your email for the confirmation message
</div>}
<div>Email: {user.email}</div>
</div>
</div>}
</div>
</li>}
<li className="list-group-item">Account Level: {capitalize(user.level)} {user.upgradeToStudentRequest === "waiting"
? "(Upgrade Requested)"
: ""}</li>
<li className="list-group-item">Signs in with:
{(user.password && user.email) && <span>
<i className="fas fa-key ml-2"></i> <span className="small mr-1">Password</span>
</span>}
{providers.map((provider) => (
user[provider] && <span>
<i className={`fab fa-${provider} mx-1`}></i>
</span>
))}
</li>
<li className="list-group-item">Account Level: {capitalize(level)}</li>
</ul>
</div>
</div>
<button
type="button"
className="btn btn-secondary mr-3 mb-4"
onClick={handleSignOut}
data-testid="signoutButton"
>
<i className="fa fa-sign-out-alt"></i> Sign Out
</button>
{user.level === "student" && <p><strong>Note:</strong> If you had a student account in the previous system <em>your wordlist will be moved over to this account in a couple of days</em>.</p>}
<h4 className="mb-3">Account Admin</h4>
<div className="mb-4">
{level === "basic" && <button
type="button"
className="btn btn-outline-secondary mr-3 mb-3"
onClick={() => setShowingUpgradePrompt(true)}
data-testid="upgradeButton"
>
<i className="fa fa-level-up-alt"></i> Upgrade Account
</button>}
<button
type="button"
className="btn btn-outline-secondary mr-3 mb-3"
onClick={() => setShowingPasswordChange(true)}
>
<i className="fa fa-lock"></i> {!hasPasswordProvider ? "Add" : "Change"} Password
</button>
<button
type="button"
className="btn btn-outline-secondary mr-3 mb-3"
onClick={() => setShowingUpdateEmail(true)}
>
<i className="fa fa-envelope"></i> Update Email
</button>
</div>
<hr className="mb-4" />
<button type="button" className="d-block my-3 btn btn-outline-danger" onClick={() => setShowingDeleteConfirmation(true)}>
<i className="fa fa-trash"></i> Delete Account
</button>
<Modal show={showingDeleteConfirmation} onHide={() => setShowingDeleteConfirmation(false)}>
<Modal.Header closeButton>
<Modal.Title>Delete Account?</Modal.Title>
</Modal.Header>
<Modal.Body>Are your sure you want to delete your account? This can't be undone.</Modal.Body>
{accountDeleteError && <div className="mt-3 alert alert-warning mx-3">
<p>
<strong>{accountDeleteError}</strong>
</p>
<div className="row mb-4">
{user.level === "basic" && <div className="col-sm mb-3">
<button
type="button"
className="btn btn-secondary d-block my-3"
onClick={handleSignOut}
data-testid="signoutButton"
className="btn btn-outline-secondary"
onClick={() => setShowingUpgradePrompt(true)}
data-testid="upgradeButton"
>
<i className="fa fa-sign-out-alt"></i> Sign Out
<i className="fa fa-level-up-alt"></i> Upgrade Account
</button>
</div>}
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowingDeleteConfirmation(false)}>
No, cancel
</Button>
<Button variant="danger" onClick={handleDelete}>
Yes, delete my account
</Button>
</Modal.Footer>
</Modal>
<div className="col-sm mb-3">
<a className="btn btn-outline-secondary" href="https://account.lingdocs.com/user">
<i className="fas fa-user mr-2"></i> Edit Account
</a>
</div>
<div className="col-sm mb-3">
<button className="btn btn-outline-secondary" onClick={handleSignOut}>
<i className="fas fa-sign-out-alt mr-2"></i> Sign Out
</button>
</div>
</div>
<Modal show={showingUpgradePrompt} onHide={closeUpgrade}>
<Modal.Header closeButton>
<Modal.Title>Upgrade Account</Modal.Title>
</Modal.Header>
<Modal.Body>Enter the secret upgrade password to upgrade your account.</Modal.Body>
<Modal.Body>Enter the secret upgrade password to upgrade your account or <button className="btn btn-sm btn-outline-secondary my-2" onClick={handleUpgradeRequest}>request an upgrade</button></Modal.Body>
<div className="form-group px-3">
<label htmlFor="upgradePasswordForm">Upgrade password:</label>
<input
@ -319,84 +226,6 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
</Button>
</Modal.Footer>
</Modal>
<Modal show={showingPasswordChange} onHide={closePasswordChange}>
<Modal.Header closeButton>
<Modal.Title>{hasPasswordProvider ? "Change" : "Add"} Password</Modal.Title>
</Modal.Header>
{!hasPasswordProvider && <Modal.Body>
You can create a password here if you would like to sign in with your email and password, instead of just signing in with {defaultProviderId}.
</Modal.Body>}
<div className="form-group px-3">
<label htmlFor="newPassword">New Password:</label>
<input
type="password"
className="form-control mb-2"
id="newPassword"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setPasswordError("");
}}
/>
<label htmlFor="confirmNewPassword">Confirm New Password:</label>
<input
type="password"
className="form-control"
id="confirmNewPassword"
value={passwordConfirmed}
onChange={(e) => {
setPasswordConfirmed(e.target.value);
setPasswordError("");
}}
/>
</div>
{passwordError && <div className="mt-3 alert alert-warning mx-3">
<p>
<strong>{passwordError}</strong>
</p>
</div>}
<Modal.Footer>
{waiting && <LoadingElipses />}
<Button variant="secondary" onClick={closePasswordChange}>
Cancel
</Button>
<Button variant="primary" onClick={handlePasswordChange}>
Change Password
</Button>
</Modal.Footer>
</Modal>
<Modal show={showingUpdateEmail} onHide={closeUpdateEmail}>
<Modal.Header closeButton>
<Modal.Title>Update Email</Modal.Title>
</Modal.Header>
<div className="form-group px-3 mt-3">
<label htmlFor="newEmail">New Email:</label>
<input
type="email"
className="form-control mb-2"
id="newEmail"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
setUpdateEmailError("");
}}
/>
</div>
{updateEmailError && <div className="mt-3 alert alert-warning mx-3">
<p>
<strong>{updateEmailError}</strong>
</p>
</div>}
<Modal.Footer>
{waiting && <LoadingElipses />}
<Button variant="secondary" onClick={closeUpdateEmail}>
Cancel
</Button>
<Button variant="primary" onClick={handleUpdateEmail}>
Update Email
</Button>
</Modal.Footer>
</Modal>
</div>
);
};

View File

@ -18,11 +18,12 @@ import {
validateEntry,
} from "@lingdocs/pashto-inflector";
import Entry from "../components/Entry";
import * as BT from "../lib/backend-types";
import * as FT from "../lib/functions-types";
import {
submissionBase,
addSubmission,
} from "../lib/submissions";
import { getTextOptions } from "../lib/get-text-options";
import { Helmet } from "react-helmet";
const textFields: {field: T.DictionaryEntryTextField, label: string}[] = [
@ -116,6 +117,7 @@ function EntryEditor({ state, dictionary, searchParams }: {
setMatchingEntries(state.isolatedEntry ? searchForMatchingEntries(state.isolatedEntry.p) : []);
// eslint-disable-next-line
}, [state]);
const textOptions = getTextOptions(state);
function searchForMatchingEntries(s: string): T.DictionaryEntry[] {
return dictionary.exactPashtoSearch(s)
.filter((w) => w.ts !== state.isolatedEntry?.ts);
@ -136,18 +138,20 @@ function EntryEditor({ state, dictionary, searchParams }: {
}
}
function handleDelete() {
const submission: BT.EntryDeletion = {
...submissionBase(),
if (!state.user) return;
const submission: FT.EntryDeletion = {
...submissionBase(state.user),
type: "entry deletion",
ts: entry.ts,
};
addSubmission(submission, state.options.level);
addSubmission(submission, state.user);
setDeleted(true);
}
function handleSubmit(e: any) {
setErroneousFields([]);
setErrors([]);
e.preventDefault();
if (!state.user) return;
const result = validateEntry(entry);
if ("errors" in result) {
setErroneousFields(result.erroneousFields);
@ -155,12 +159,12 @@ function EntryEditor({ state, dictionary, searchParams }: {
return;
}
// TODO: Check complement if checkComplement
const submission: BT.NewEntry | BT.EntryEdit = {
...submissionBase(),
const submission: FT.NewEntry | FT.EntryEdit = {
...submissionBase(state.user),
type: entry.ts === 1 ? "new entry" : "entry edit",
entry: { ...entry, ts: entry.ts === 1 ? Date.now() : entry.ts },
};
addSubmission(submission, state.options.level);
addSubmission(submission, state.user);
setSubmitted(true);
// TODO: Remove from suggestions
// if (willDeleteSuggestion && sTs) {
@ -179,15 +183,15 @@ function EntryEditor({ state, dictionary, searchParams }: {
})();
const linkField: { field: "l", label: string | JSX.Element } = {
field: "l",
label: <>link {entry.l ? (complement ? <InlinePs opts={state.options.textOptions}>{complement}</InlinePs> : "not found") : ""}</>,
label: <>link {entry.l ? (complement ? <InlinePs opts={textOptions}>{complement}</InlinePs> : "not found") : ""}</>,
};
return <div className="width-limiter" style={{ marginBottom: "70px" }}>
<Helmet>
<link rel="canonical" href="https://dictionary.lingdocs.com/edit" />
<title>Edit - LingDocs Pashto Dictionary</title>
</Helmet>
{state.isolatedEntry && <Entry nonClickable entry={state.isolatedEntry} textOptions={state.options.textOptions} isolateEntry={() => null} />}
{suggestedWord && <InlinePs opts={state.options.textOptions}>{suggestedWord}</InlinePs>}
{state.isolatedEntry && <Entry nonClickable entry={state.isolatedEntry} textOptions={textOptions} isolateEntry={() => null} />}
{suggestedWord && <InlinePs opts={textOptions}>{suggestedWord}</InlinePs>}
{comment && <p>Comment: "{comment}"</p>}
{submitted ? "Edit submitted/saved" : deleted ? "Entry Deleted" :
<div>
@ -196,7 +200,7 @@ function EntryEditor({ state, dictionary, searchParams }: {
{matchingEntries.map((entry) => (
<div key={entry.ts}>
<Link to={`/edit?id=${entry.ts}`} className="plain-link">
<InlinePs opts={state.options.textOptions}>{entry}</InlinePs>
<InlinePs opts={textOptions}>{entry}</InlinePs>
</Link>
</div>
))}
@ -328,12 +332,12 @@ function EntryEditor({ state, dictionary, searchParams }: {
</ul>
</div>}
</form>
{inflections && <InflectionsTable inf={inflections} textOptions={state.options.textOptions} />}
{inflections && <InflectionsTable inf={inflections} textOptions={textOptions} />}
{/* TODO: aay tail from state options */}
<ConjugationViewer
entry={entry}
complement={complement}
textOptions={state.options.textOptions}
textOptions={textOptions}
/>
</div>}
</div>;

View File

@ -7,7 +7,6 @@
*/
import { useEffect, useState } from "react";
import { auth } from "../lib/firebase";
import {
ConjugationViewer,
InflectionsTable,
@ -28,12 +27,11 @@ import {
deleteWordFromWordlist,
hasAttachment,
} from "../lib/wordlist-database";
import {
wordlistEnabled,
} from "../lib/level-management";
import { wordlistEnabled } from "../lib/level-management";
import AudioPlayButton from "../components/AudioPlayButton";
import { Helmet } from "react-helmet";
import { Modal } from "react-bootstrap";
import { getTextOptions } from "../lib/get-text-options";
function IsolatedEntry({ state, dictionary, isolateEntry }: {
state: State,
@ -50,14 +48,16 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
setEditSubmitted(false);
}, [state]);
const wordlistWord = state.wordlist.find((w) => w.entry.ts === state.isolatedEntry?.ts);
const textOptions = getTextOptions(state);
function submitEdit() {
if (!state.isolatedEntry) return;
if (!state.user) return;
addSubmission({
...submissionBase(),
...submissionBase(state.user),
type: "edit suggestion",
entry: state.isolatedEntry,
comment,
}, state.options.level);
}, state.user);
setEditing(false);
setComment("");
setEditSubmitted(true);
@ -104,14 +104,14 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
<Entry
nonClickable
entry={entry}
textOptions={state.options.textOptions}
textOptions={textOptions}
isolateEntry={isolateEntry}
/>
</div>
{auth.currentUser &&
{state.user &&
<div className="col-4">
<div className="d-flex flex-row justify-content-end">
{state.options.level === "editor" &&
{state.user.level === "editor" &&
<Link to={`/edit?id=${entry.ts}`} className="plain-link">
<div
className="clickable mr-3"
@ -128,7 +128,7 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
>
<i className="fa fa-pen"></i>
</div>
{wordlistEnabled(state) && <div
{wordlistEnabled(state.user) && <div
className="clickable"
data-testid={wordlistWord ? "fullStarButton" : "emptyStarButton"}
onClick={wordlistWord
@ -180,12 +180,12 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
</div>
}
{editSubmitted && <p>Thank you for your help!</p>}
{inflections && <InflectionsTable inf={inflections} textOptions={state.options.textOptions} />}
{inflections && <InflectionsTable inf={inflections} textOptions={textOptions} />}
{/* TODO: State options for tail type here */}
<ConjugationViewer
entry={entry}
complement={complement}
textOptions={state.options.textOptions}
textOptions={textOptions}
/>
{relatedEntries && <>
{relatedEntries.length ?
@ -209,7 +209,7 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
<Modal.Title>Delete from wordlist?</Modal.Title>
</Modal.Header>
<Modal.Body>Delete <InlinePs
opts={state.options.textOptions}
opts={textOptions}
>{{ p: entry.p, f: entry.f }}</InlinePs> from your wordlist?
</Modal.Body>
<Modal.Footer>

View File

@ -129,10 +129,14 @@ const booleanOptions: {
function Options({
options,
state,
optionsDispatch,
textOptionsDispatch,
}: {
options: Options,
state: State,
optionsDispatch: (action: OptionsAction) => void,
textOptionsDispatch: (action: TextOptionsAction) => void,
}) {
return <div style={{ maxWidth: "700px", marginBottom: "150px" }}>
<Helmet>
@ -152,7 +156,7 @@ function Options({
<td><kbd>ctrl / </kbd> + <kbd>b</kbd></td>
<td>clear search</td>
</tr>
{wordlistEnabled(options.level) && <tr>
{wordlistEnabled(state.user) && <tr>
<td><kbd>ctrl / </kbd> + <kbd>\</kbd></td>
<td>show/hide wordlist</td>
</tr>}
@ -173,7 +177,7 @@ function Options({
handleChange={(p) => optionsDispatch({ type: "changeSearchBarPosition", payload: p as SearchBarPosition })}
/>
<div className="small mt-2">Bottom position doesn't work well with iPhones.</div>
{wordlistEnabled(options.level) && <>
{wordlistEnabled(state.user) && <>
<h4 className="mt-3">Show Number of Wordlist Words for Review</h4>
<ButtonSelect
small
@ -186,22 +190,22 @@ function Options({
<ButtonSelect
small
options={fontSizeOptions}
value={options.textOptions.pTextSize}
handleChange={(p) => optionsDispatch({ type: "changePTextSize", payload: p as PTextSize })}
value={options.textOptionsRecord.textOptions.pTextSize}
handleChange={(p) => textOptionsDispatch({ type: "changePTextSize", payload: p as PTextSize })}
/>
<h4 className="mt-3">Diacritics</h4>
<ButtonSelect
small
options={booleanOptions}
value={options.textOptions.diacritics.toString()}
handleChange={(p) => optionsDispatch({ type: "changeDiacritics", payload: p === "true" })}
value={options.textOptionsRecord.textOptions.diacritics.toString()}
handleChange={(p) => textOptionsDispatch({ type: "changeDiacritics", payload: p === "true" })}
/>
<h4 className="mt-3">Pashto Spelling</h4>
<ButtonSelect
small
options={spellingOptions}
value={options.textOptions.spelling}
handleChange={(p) => optionsDispatch({ type: "changeSpelling", payload: p as T.Spelling })}
value={options.textOptionsRecord.textOptions.spelling}
handleChange={(p) => textOptionsDispatch({ type: "changeSpelling", payload: p as T.Spelling })}
/>
{/* NEED TO UPDATE THE PHONETICS DIALECT OPTION THING */}
{/* <h4 className="mt-3">Phonetics</h4>

View File

@ -7,7 +7,7 @@
*/
import { useEffect, useState } from "react";
import * as BT from "../lib/backend-types";
import * as FT from "../lib/functions-types";
import {
submissionBase,
addSubmission,
@ -15,7 +15,6 @@ import {
import { isPashtoScript } from "../lib/is-pashto";
import Entry from "../components/Entry";
import { Helmet } from "react-helmet";
import { auth } from "../lib/firebase";
import { allEntries } from "../lib/dictionary";
import {
standardizePashto,
@ -24,6 +23,7 @@ import {
} from "@lingdocs/pashto-inflector";
import InflectionSearchResult from "../components/InflectionSearchResult";
import { searchAllInflections } from "../lib/search-all-inflections";
import { getTextOptions } from "../lib/get-text-options";
const inflectionSearchIcon = "fas fa-search-plus";
@ -42,6 +42,7 @@ function Results({ state, isolateEntry }: {
const [pashto, setPashto] = useState<string>("");
const [phonetics, setPhonetics] = useState<string>("");
const [english, setEnglish] = useState<string>("");
const textOptions = getTextOptions(state);
useEffect(() => {
setPowerResults(undefined);
}, [state.searchValue])
@ -63,16 +64,17 @@ function Results({ state, isolateEntry }: {
}
function submitSuggestion(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
event.preventDefault();
if (!state.user) return;
const p = pashto;
const f = phonetics;
const e = english;
const newEntry: BT.EntrySuggestion = {
...submissionBase(),
const newEntry: FT.EntrySuggestion = {
...submissionBase(state.user),
type: "entry suggestion",
entry: { ts: 0, i: 0, p, f, g: "", e },
comment,
};
addSubmission(newEntry, state.options.level);
addSubmission(newEntry, state.user);
setSuggestionState("received");
}
function handlePowerSearch() {
@ -82,7 +84,7 @@ function Results({ state, isolateEntry }: {
const allDocs = allEntries();
const results = searchAllInflections(
allDocs,
prepValueForSearch(state.searchValue, state.options.textOptions),
prepValueForSearch(state.searchValue, textOptions),
);
setPowerResults(results);
}, 20);
@ -91,7 +93,7 @@ function Results({ state, isolateEntry }: {
<Helmet>
<title>LingDocs Pashto Dictionary</title>
</Helmet>
{(auth.currentUser && (window.location.pathname !== "/word") && suggestionState === "none" && powerResults === undefined) && <button
{(state.user && (window.location.pathname !== "/word") && suggestionState === "none" && powerResults === undefined) && <button
type="button"
className={`btn btn-outline-secondary bg-white entry-suggestion-button${state.options.searchBarPosition === "bottom" ? " entry-suggestion-button-with-bottom-searchbar" : ""}`}
onClick={startSuggestion}
@ -118,14 +120,14 @@ function Results({ state, isolateEntry }: {
<Entry
key={p.entry.i}
entry={p.entry}
textOptions={state.options.textOptions}
textOptions={textOptions}
isolateEntry={isolateEntry}
/>
<div className="mb-3 ml-2">
{p.results.map((result: InflectionSearchResult, i) => (
<InflectionSearchResult
key={"inf-result" + i}
textOptions={state.options.textOptions}
textOptions={textOptions}
result={result}
entry={p.entry}
/>
@ -138,11 +140,11 @@ function Results({ state, isolateEntry }: {
<Entry
key={entry.i}
entry={entry}
textOptions={state.options.textOptions}
textOptions={textOptions}
isolateEntry={isolateEntry}
/>
))}
{(auth.currentUser && (suggestionState === "editing")) && <div className="my-3">
{(state.user && (suggestionState === "editing")) && <div className="my-3">
<h5 className="mb-3">Suggest an entry for the dictionary:</h5>
<div className="form-group mt-4" style={{ maxWidth: "500px" }}>
<div className="row mb-2">

View File

@ -1,6 +1,6 @@
import Entry from "../components/Entry";
import { Link } from "react-router-dom";
import * as BT from "../lib/backend-types";
import * as FT from "../lib/functions-types";
import {
deleteFromLocalDb,
} from "../lib/pouch-dbs";
@ -8,8 +8,9 @@ import {
Types as T,
} from "@lingdocs/pashto-inflector";
import { Helmet } from "react-helmet";
import { getTextOptions } from "../lib/get-text-options";
function ReviewTask({ reviewTask, textOptions }: { reviewTask: BT.ReviewTask, textOptions: T.TextOptions }) {
function ReviewTask({ reviewTask, textOptions }: { reviewTask: FT.ReviewTask, textOptions: T.TextOptions }) {
function handleDelete() {
deleteFromLocalDb("reviewTasks", reviewTask._id);
}
@ -40,7 +41,7 @@ function ReviewTask({ reviewTask, textOptions }: { reviewTask: BT.ReviewTask, te
</div>}
<Entry textOptions={textOptions} entry={reviewTask.entry} />
<div className="mb-2">"{reviewTask.comment}"</div>
<div className="small">{reviewTask.user.displayName} - {reviewTask.user.email}</div>
<div className="small">{reviewTask.user.name} - {reviewTask.user.email}</div>
</div>
</div>
</Link>
@ -50,13 +51,14 @@ function ReviewTask({ reviewTask, textOptions }: { reviewTask: BT.ReviewTask, te
}
export default function ReviewTasks({ state }: { state: State }) {
const textOptions = getTextOptions(state);
return <div className="width-limiter" style={{ marginBottom: "70px" }}>
<Helmet>
<title>Review Tasks - LingDocs Pashto Dictionary</title>
</Helmet>
<h3 className="mb-4">Review Tasks</h3>
{state.reviewTasks.length ?
state.reviewTasks.map((reviewTask, i) => <ReviewTask key={i} reviewTask={reviewTask} textOptions={state.options.textOptions} />)
state.reviewTasks.map((reviewTask, i) => <ReviewTask key={i} reviewTask={reviewTask} textOptions={textOptions} />)
: <p>None</p>
}
</div>;

View File

@ -44,6 +44,8 @@ import AudioPlayButton from "../components/AudioPlayButton";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime.js";
import hitBottom from "../lib/hitBottom";
import { getTextOptions } from "../lib/get-text-options";
const cleanupIcon = "broom";
dayjs.extend(relativeTime);
@ -94,6 +96,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
// eslint-disable-next-line
}, []);
const toReview = forReview(state.wordlist);
const textOptions = getTextOptions(state);
function handleScroll() {
// TODO: DON'T HAVE ENDLESS PAGE INCREASING
if (hitBottom() && state.options.wordlistMode === "browse") {
@ -116,7 +119,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
}
function handleSearchValueChange(value: string) {
setWordlistSearchValue(value);
const results = value ? searchWordlist(value, state.wordlist, state.options.textOptions) : [];
const results = value ? searchWordlist(value, state.wordlist, textOptions) : [];
setFilteredWords(results);
}
async function handleGetWordlistCSV() {
@ -152,7 +155,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
return <div className="mb-4">
<Entry
entry={word.entry}
textOptions={state.options.textOptions}
textOptions={textOptions}
isolateEntry={() => handleWordClickBrowse(word._id)}
/>
{hasAttachment(word, "audio") && <AudioPlayButton word={word} />}
@ -217,14 +220,14 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
<div className="card-body">
<h6 className="card-title text-center">
{state.options.wordlistReviewLanguage === "Pashto"
? <InlinePs opts={state.options.textOptions}>{{ p: word.entry.p, f: word.entry.f }}</InlinePs>
? <InlinePs opts={textOptions}>{{ p: word.entry.p, f: word.entry.f }}</InlinePs>
: word.entry.e
}
</h6>
{beingQuizzed && <div className="card-text text-center">
{state.options.wordlistReviewLanguage === "Pashto"
? <div>{word.entry.e}</div>
: <InlinePs opts={state.options.textOptions}>
: <InlinePs opts={textOptions}>
{{ p: word.entry.p, f: word.entry.f }}
</InlinePs>
}
@ -311,7 +314,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
const { e, ...ps } = nextUp.entry;
return <div>
<div className="lead my-3">None to review</div>
<p>Next word up for review <strong>{dayjs().to(nextUp.dueDate)}</strong>: <InlinePs opts={state.options.textOptions}>
<p>Next word up for review <strong>{dayjs().to(nextUp.dueDate)}</strong>: <InlinePs opts={textOptions}>
{removeFVariants(ps)}
</InlinePs></p>
</div>;

View File

@ -17,12 +17,16 @@ type SearchBarPosition = "top" | "bottom";
type WordlistMode = "browse" | "review";
type TextOptionsRecord = {
lastModified: import("./lib/account-types").TimeStamp,
textOptions: import("@lingdocs/pashto-inflector").Types.TextOptions,
};
type Options = {
language: Language,
searchType: SearchType,
theme: Theme,
textOptions: import("@lingdocs/pashto-inflector").Types.TextOptions,
level: UserLevel,
textOptionsRecord: TextOptionsRecord,
wordlistMode: WordlistMode,
wordlistReviewLanguage: Language,
wordlistReviewBadge: boolean,
@ -39,35 +43,21 @@ type State = {
isolatedEntry: import("@lingdocs/pashto-inflector").Types.DictionaryEntry | undefined,
results: import("@lingdocs/pashto-inflector").Types.DictionaryEntry[],
wordlist: WordlistWord[],
reviewTasks: import("./lib/backend-types").ReviewTask[],
reviewTasks: import("./lib/functions-types").ReviewTask[],
dictionaryInfo: import("@lingdocs/pashto-inflector").Types.DictionaryInfo | undefined,
user: undefined | import("./lib/account-types").LingdocsUser,
}
type OptionsAction = {
type: "toggleSearchType",
} | {
type: "toggleLanguage",
} | {
type: "changePTextSize",
payload: PTextSize,
} | {
type: "changeTheme",
payload: Theme,
} | {
type: "changeSearchBarPosition",
payload: SearchBarPosition,
} | {
type: "changeSpelling",
payload: import("@lingdocs/pashto-inflector").Types.Spelling,
} | {
type: "changePhonetics",
payload: import("@lingdocs/pashto-inflector").Types.Phonetics,
} | {
type: "changeDialect",
payload: import("@lingdocs/pashto-inflector").Types.Dialect,
} | {
type: "changeDiacritics",
payload: boolean,
} | {
type: "changeUserLevel",
payload: UserLevel,
@ -80,6 +70,26 @@ type OptionsAction = {
} | {
type: "changeWordlistReviewBadge",
payload: boolean,
} | {
type: "updateTextOptionsRecord",
payload: TextOptionsRecord,
};
type TextOptionsAction = {
type: "changePTextSize",
payload: PTextSize,
} | {
type: "changeSpelling",
payload: import("@lingdocs/pashto-inflector").Types.Spelling,
} | {
type: "changePhonetics",
payload: import("@lingdocs/pashto-inflector").Types.Phonetics,
} | {
type: "changeDialect",
payload: import("@lingdocs/pashto-inflector").Types.Dialect,
} | {
type: "changeDiacritics",
payload: boolean,
};
type DictionaryAPI = {

File diff suppressed because it is too large Load Diff