Merge branch 'dev'
This commit is contained in:
commit
5b7006b2d3
|
@ -29,5 +29,5 @@ jobs:
|
||||||
cd ..
|
cd ..
|
||||||
cd functions
|
cd functions
|
||||||
npm install
|
npm install
|
||||||
- name: deploy functions
|
- name: deploy functions and hosting routes
|
||||||
run: firebase deploy -f --token ${FIREBASE_TOKEN}
|
run: firebase deploy -f --token ${FIREBASE_TOKEN}
|
|
@ -3,7 +3,7 @@ name: Functions CI
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
- '*'
|
- '*'
|
||||||
paths:
|
paths:
|
||||||
|
|
62
README.md
62
README.md
|
@ -138,12 +138,72 @@ pm2 start ecosystem.config.js
|
||||||
pm2 save
|
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
|
#### CouchDB
|
||||||
|
|
||||||
When a user upgrades their account level to `student` or `editor`:
|
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
|
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).
|
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).
|
||||||
|
|
||||||
|
|
|
@ -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"
|
|
|
@ -1,4 +0,0 @@
|
||||||
# auth.lingdocs.com
|
|
||||||
|
|
||||||
Auth service for LingDocs (in progress, not usable yet)
|
|
||||||
|
|
|
@ -2,25 +2,27 @@ import Nano from "nano";
|
||||||
import { DocumentInsertResponse } from "nano";
|
import { DocumentInsertResponse } from "nano";
|
||||||
import { getTimestamp } from "./time-utils";
|
import { getTimestamp } from "./time-utils";
|
||||||
import env from "./env-vars";
|
import env from "./env-vars";
|
||||||
|
import * as T from "../../../website/src/lib/account-types";
|
||||||
|
|
||||||
const nano = Nano(env.couchDbURL);
|
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 {
|
return {
|
||||||
...user,
|
...user,
|
||||||
lastActive: getTimestamp(),
|
lastActive: getTimestamp(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLastLogin(user: LingdocsUser): LingdocsUser {
|
export function updateLastLogin(user: T.LingdocsUser): T.LingdocsUser {
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
lastLogin: getTimestamp(),
|
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;
|
if (response.ok !== true) return undefined;
|
||||||
return {
|
return {
|
||||||
...user,
|
...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({
|
const user = await usersDb.find({
|
||||||
selector: field === "githubId"
|
selector: field === "githubId"
|
||||||
? { github: { id: value }}
|
? { github: { id: value }}
|
||||||
|
@ -42,10 +44,15 @@ export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "
|
||||||
if (!user.docs.length) {
|
if (!user.docs.length) {
|
||||||
return undefined;
|
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 res = await usersDb.insert(user);
|
||||||
const newUser = processAPIResponse(user, res);
|
const newUser = processAPIResponse(user, res);
|
||||||
if (!newUser) {
|
if (!newUser) {
|
||||||
|
@ -54,34 +61,53 @@ export async function insertLingdocsUser(user: LingdocsUser): Promise<LingdocsUs
|
||||||
return newUser;
|
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);
|
const user = await getLingdocsUser("userId", uuid);
|
||||||
|
await deleteCouchDbAuthUser(uuid);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
// TODO: cleanup userdbs etc
|
// TODO: cleanup userdbs etc
|
||||||
// TODO: Better type certainty here... obviously there is an _id and _rev here
|
// TODO: Better type certainty here... obviously there is an _id and _rev here
|
||||||
await usersDb.destroy(user._id as string, user._rev as string);
|
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: 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
|
// 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??
|
// TODO: OR USE REDUCER??
|
||||||
{ name: string } |
|
{ name: string } |
|
||||||
{ name?: string, email: string, emailVerified: Hash } |
|
{ name?: string, email: string, emailVerified: T.Hash } |
|
||||||
{ email: string, emailVerified: true } |
|
{ email: string, emailVerified: true } |
|
||||||
{ emailVerified: Hash } |
|
{ emailVerified: T.Hash } |
|
||||||
{ emailVerified: true } |
|
{ emailVerified: true } |
|
||||||
{ password: Hash } |
|
{ password: T.Hash } |
|
||||||
{ google: GoogleProfile | undefined } |
|
{ google: T.GoogleProfile | undefined } |
|
||||||
{ github: GitHubProfile | undefined } |
|
{ github: T.GitHubProfile | undefined } |
|
||||||
{ twitter: TwitterProfile | undefined } |
|
{ twitter: T.TwitterProfile | undefined } |
|
||||||
{
|
{
|
||||||
passwordReset: {
|
passwordReset: {
|
||||||
tokenHash: Hash,
|
tokenHash: T.Hash,
|
||||||
requestedOn: TimeStamp,
|
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);
|
const user = await getLingdocsUser("userId", uuid);
|
||||||
if (!user) throw new Error("unable to update - user not found " + uuid);
|
if (!user) throw new Error("unable to update - user not found " + uuid);
|
||||||
if ("password" in toUpdate) {
|
if ("password" in toUpdate) {
|
||||||
|
@ -96,3 +122,74 @@ export async function updateLingdocsUser(uuid: UUID, toUpdate:
|
||||||
...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;
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ const names = [
|
||||||
"LINGDOCS_ACCOUNT_TWITTER_CLIENT_SECRET",
|
"LINGDOCS_ACCOUNT_TWITTER_CLIENT_SECRET",
|
||||||
"LINGDOCS_ACCOUNT_GITHUB_CLIENT_SECRET",
|
"LINGDOCS_ACCOUNT_GITHUB_CLIENT_SECRET",
|
||||||
"LINGDOCS_ACCOUNT_RECAPTCHA_SECRET",
|
"LINGDOCS_ACCOUNT_RECAPTCHA_SECRET",
|
||||||
|
"LINGDOCS_ACCOUNT_UPGRADE_PASSWORD",
|
||||||
];
|
];
|
||||||
|
|
||||||
const values = names.map((name) => ({
|
const values = names.map((name) => ({
|
||||||
|
@ -31,4 +32,5 @@ export default {
|
||||||
twitterClientSecret: values[6].value,
|
twitterClientSecret: values[6].value,
|
||||||
githubClientSecret: values[7].value,
|
githubClientSecret: values[7].value,
|
||||||
recaptchaSecret: values[8].value,
|
recaptchaSecret: values[8].value,
|
||||||
|
upgradePassword: values[9].value,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import inProd from "./inProd";
|
import inProd from "./inProd";
|
||||||
import env from "./env-vars";
|
import env from "./env-vars";
|
||||||
|
import * as T from "../../../website/src/lib/account-types";
|
||||||
|
|
||||||
type Address = string | { name: string, address: string };
|
type Address = string | { name: string, address: string };
|
||||||
|
|
||||||
const from: Address = {
|
const adminAddress: Address = {
|
||||||
name: "LingDocs Admin",
|
name: "LingDocs Admin",
|
||||||
address: "admin@lingdocs.com",
|
address: "admin@lingdocs.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAddress(user: LingdocsUser): Address {
|
function getAddress(user: T.LingdocsUser): Address {
|
||||||
// TODO: Guard against ""
|
// TODO: Guard against ""
|
||||||
if (!user.name) return user.email || "";
|
if (!user.name) return user.email || "";
|
||||||
return {
|
return {
|
||||||
|
@ -30,31 +31,52 @@ const transporter = nodemailer.createTransport({
|
||||||
|
|
||||||
async function sendEmail(to: Address, subject: string, text: string) {
|
async function sendEmail(to: Address, subject: string, text: string) {
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from,
|
from: adminAddress,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: MAKE THIS
|
// TODO: MAKE THIS A URL ACROSS PROJECT
|
||||||
const baseURL = inProd ? "https://account.lingdocs.com" : "http://localhost:4000";
|
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},
|
const content = `Hello ${user.name},
|
||||||
|
|
||||||
Please verify your email by visiting this link: ${baseURL}/email-verification/${user.userId}/${token}
|
Please verify your email by visiting this link: ${baseURL}/email-verification/${user.userId}/${token}
|
||||||
|
|
||||||
LingDocs Admin`;
|
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},
|
const content = `Hello ${user.name},
|
||||||
|
|
||||||
Please visit this link to reset your password: ${baseURL}/password-reset/${user.userId}/${token}
|
Please visit this link to reset your password: ${baseURL}/password-reset/${user.userId}/${token}
|
||||||
|
|
||||||
LingDocs Admin`;
|
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);
|
||||||
}
|
}
|
|
@ -1,23 +1,24 @@
|
||||||
import { hash, compare } from "bcryptjs";
|
import { hash, compare } from "bcryptjs";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import base64url from "base64url";
|
import base64url from "base64url";
|
||||||
|
import * as T from "../../../website/src/lib/account-types";
|
||||||
|
|
||||||
const tokenSize = 24;
|
const tokenSize = 24;
|
||||||
|
|
||||||
export async function getHash(p: string): Promise<Hash> {
|
export async function getHash(p: string): Promise<T.Hash> {
|
||||||
return await hash(p, 10) as 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 token = getURLToken();
|
||||||
const h = await getHash(token);
|
const h = await getHash(token);
|
||||||
return { token, hash: h };
|
return { token, hash: h };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getURLToken(): URLToken {
|
export function getURLToken(): T.URLToken {
|
||||||
return base64url(randomBytes(tokenSize)) as 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);
|
return compare(s, hash);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
export function getTimestamp(): TimeStamp {
|
import * as T from "../../../website/src/lib/account-types";
|
||||||
return Date.now() as TimeStamp;
|
|
||||||
|
export function getTimestamp(): T.TimeStamp {
|
||||||
|
return Date.now() as T.TimeStamp;
|
||||||
}
|
}
|
|
@ -1,18 +1,26 @@
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { insertLingdocsUser } from "../lib/couch-db";
|
import {
|
||||||
|
insertLingdocsUser,
|
||||||
|
addCouchDbAuthUser,
|
||||||
|
updateLingdocsUser,
|
||||||
|
} from "../lib/couch-db";
|
||||||
import {
|
import {
|
||||||
getHash,
|
getHash,
|
||||||
getEmailTokenAndHash,
|
getEmailTokenAndHash,
|
||||||
} from "../lib/password-utils";
|
} from "../lib/password-utils";
|
||||||
import { getTimestamp } from "../lib/time-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 { outsideProviders } from "../middleware/setup-passport";
|
||||||
|
import * as T from "../../../website/src/lib/account-types";
|
||||||
|
|
||||||
function getUUID(): UUID {
|
function getUUID(): T.UUID {
|
||||||
return uuidv4() as UUID;
|
return uuidv4() as T.UUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canRemoveOneOutsideProvider(user: LingdocsUser): boolean {
|
export function canRemoveOneOutsideProvider(user: T.LingdocsUser): boolean {
|
||||||
if (user.email && user.password) {
|
if (user.email && user.password) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -20,7 +28,7 @@ export function canRemoveOneOutsideProvider(user: LingdocsUser): boolean {
|
||||||
return providersPresent.length > 1;
|
return providersPresent.length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVerifiedEmail({ emails }: ProviderProfile): string | false {
|
export function getVerifiedEmail({ emails }: T.ProviderProfile): string | false {
|
||||||
return (
|
return (
|
||||||
emails
|
emails
|
||||||
&& emails.length
|
&& emails.length
|
||||||
|
@ -29,7 +37,7 @@ export function getVerifiedEmail({ emails }: ProviderProfile): string | false {
|
||||||
) ? emails[0].value : 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) {
|
if (!profile.emails || profile.emails.length === 0) {
|
||||||
return { email: undefined, verified: false };
|
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: {
|
export async function createNewUser(input: {
|
||||||
strategy: "local",
|
strategy: "local",
|
||||||
email: string,
|
email: string,
|
||||||
|
@ -49,20 +84,20 @@ export async function createNewUser(input: {
|
||||||
passwordPlainText: string,
|
passwordPlainText: string,
|
||||||
} | {
|
} | {
|
||||||
strategy: "github",
|
strategy: "github",
|
||||||
profile: GitHubProfile,
|
profile: T.GitHubProfile,
|
||||||
} | {
|
} | {
|
||||||
strategy: "google",
|
strategy: "google",
|
||||||
profile: GoogleProfile,
|
profile: T.GoogleProfile,
|
||||||
} | {
|
} | {
|
||||||
strategy: "twitter",
|
strategy: "twitter",
|
||||||
profile: TwitterProfile,
|
profile: T.TwitterProfile,
|
||||||
}): Promise<LingdocsUser> {
|
}): Promise<T.LingdocsUser> {
|
||||||
const userId = getUUID();
|
const userId = getUUID();
|
||||||
const now = getTimestamp();
|
const now = getTimestamp();
|
||||||
if (input.strategy === "local") {
|
if (input.strategy === "local") {
|
||||||
const email = await getEmailTokenAndHash();
|
const email = await getEmailTokenAndHash();
|
||||||
const password = await getHash(input.passwordPlainText);
|
const password = await getHash(input.passwordPlainText);
|
||||||
const newUser: LingdocsUser = {
|
const newUser: T.LingdocsUser = {
|
||||||
_id: userId,
|
_id: userId,
|
||||||
userId,
|
userId,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
|
@ -73,6 +108,7 @@ export async function createNewUser(input: {
|
||||||
tests: [],
|
tests: [],
|
||||||
lastLogin: now,
|
lastLogin: now,
|
||||||
lastActive: now,
|
lastActive: now,
|
||||||
|
userTextOptionsRecord: undefined,
|
||||||
};
|
};
|
||||||
const user = await insertLingdocsUser(newUser);
|
const user = await insertLingdocsUser(newUser);
|
||||||
sendVerificationEmail(user, email.token).catch(console.error);
|
sendVerificationEmail(user, email.token).catch(console.error);
|
||||||
|
@ -80,7 +116,7 @@ export async function createNewUser(input: {
|
||||||
}
|
}
|
||||||
// GitHub || Twitter
|
// GitHub || Twitter
|
||||||
if (input.strategy === "github" || input.strategy === "twitter") {
|
if (input.strategy === "github" || input.strategy === "twitter") {
|
||||||
const newUser: LingdocsUser = {
|
const newUser: T.LingdocsUser = {
|
||||||
_id: userId,
|
_id: userId,
|
||||||
userId,
|
userId,
|
||||||
emailVerified: false,
|
emailVerified: false,
|
||||||
|
@ -90,6 +126,7 @@ export async function createNewUser(input: {
|
||||||
tests: [],
|
tests: [],
|
||||||
lastLogin: now,
|
lastLogin: now,
|
||||||
lastActive: now,
|
lastActive: now,
|
||||||
|
userTextOptionsRecord: undefined,
|
||||||
};
|
};
|
||||||
const user = await insertLingdocsUser(newUser);
|
const user = await insertLingdocsUser(newUser);
|
||||||
return user;
|
return user;
|
||||||
|
@ -99,7 +136,7 @@ export async function createNewUser(input: {
|
||||||
const { email, verified } = getEmailFromGoogleProfile(input.profile);
|
const { email, verified } = getEmailFromGoogleProfile(input.profile);
|
||||||
if (email && !verified) {
|
if (email && !verified) {
|
||||||
const em = await getEmailTokenAndHash();
|
const em = await getEmailTokenAndHash();
|
||||||
const newUser: LingdocsUser = {
|
const newUser: T.LingdocsUser = {
|
||||||
_id: userId,
|
_id: userId,
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
|
@ -110,12 +147,13 @@ export async function createNewUser(input: {
|
||||||
tests: [],
|
tests: [],
|
||||||
lastActive: now,
|
lastActive: now,
|
||||||
level: "basic",
|
level: "basic",
|
||||||
|
userTextOptionsRecord: undefined,
|
||||||
}
|
}
|
||||||
const user = await insertLingdocsUser(newUser);
|
const user = await insertLingdocsUser(newUser);
|
||||||
sendVerificationEmail(user, em.token);
|
sendVerificationEmail(user, em.token);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
const newUser: LingdocsUser = {
|
const newUser: T.LingdocsUser = {
|
||||||
_id: userId,
|
_id: userId,
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
|
@ -126,6 +164,7 @@ export async function createNewUser(input: {
|
||||||
tests: [],
|
tests: [],
|
||||||
lastActive: now,
|
lastActive: now,
|
||||||
level: "basic",
|
level: "basic",
|
||||||
|
userTextOptionsRecord: undefined,
|
||||||
}
|
}
|
||||||
const user = await insertLingdocsUser(newUser);
|
const user = await insertLingdocsUser(newUser);
|
||||||
return user;
|
return user;
|
||||||
|
|
|
@ -16,6 +16,8 @@ import {
|
||||||
getVerifiedEmail,
|
getVerifiedEmail,
|
||||||
} from "../lib/user-utils";
|
} from "../lib/user-utils";
|
||||||
import env from "../lib/env-vars";
|
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"];
|
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) {
|
async function(req: any, accessToken: any, refreshToken: any, profileRaw: any, done: any) {
|
||||||
// not getting refresh token
|
// not getting refresh token
|
||||||
const { _json, _raw, ...profile } = profileRaw;
|
const { _json, _raw, ...profile } = profileRaw;
|
||||||
const ghProfile: GitHubProfile = { ...profile, accessToken };
|
const ghProfile: T.GitHubProfile = { ...profile, accessToken };
|
||||||
try {
|
try {
|
||||||
if (req.isAuthenticated()) {
|
if (req.isAuthenticated()) {
|
||||||
if (!req.user) done(new Error("user lost"));
|
if (!req.user) done(new Error("user lost"));
|
||||||
|
@ -142,14 +144,14 @@ function setupPassport(passport: PassportStatic) {
|
||||||
cb(null, user.userId);
|
cb(null, user.userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
passport.deserializeUser(async (userId: UUID, cb) => {
|
passport.deserializeUser(async (userId: T.UUID, cb) => {
|
||||||
try {
|
try {
|
||||||
const user = await getLingdocsUser("userId", userId);
|
const user = await getLingdocsUser("userId", userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
cb(null, false);
|
cb(null, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newUser = await insertLingdocsUser(updateLastActive(user));
|
const newUser = await updateLingdocsUser(userId, { lastActive: getTimestamp() });
|
||||||
cb(null, newUser);
|
cb(null, newUser);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb(err, null);
|
cb(err, null);
|
||||||
|
|
|
@ -20,8 +20,7 @@ function setupSession(app: Express) {
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 7 * 30 * 6,
|
maxAge: 1000 * 60 * 60 * 24 * 7 * 30 * 6,
|
||||||
secure: inProd,
|
secure: inProd,
|
||||||
domain: inProd ? "lingdocs.com" : undefined,
|
domain: inProd ? "lingdocs.com" : undefined,
|
||||||
// TODO: TRY TO SET TO TRUE
|
httpOnly: true,
|
||||||
httpOnly: false,
|
|
||||||
},
|
},
|
||||||
store: inProd
|
store: inProd
|
||||||
? new RedisStore({ client: redis.createClient() })
|
? new RedisStore({ client: redis.createClient() })
|
||||||
|
|
|
@ -1,19 +1,28 @@
|
||||||
import express, { Response } from "express";
|
import express, { Response } from "express";
|
||||||
import {
|
import {
|
||||||
deleteLingdocsUser,
|
deleteLingdocsUser,
|
||||||
|
getLingdocsUser,
|
||||||
updateLingdocsUser,
|
updateLingdocsUser,
|
||||||
|
deleteCouchDbAuthUser,
|
||||||
} from "../lib/couch-db";
|
} from "../lib/couch-db";
|
||||||
import {
|
import {
|
||||||
getHash,
|
getHash,
|
||||||
getURLToken,
|
|
||||||
compareToHash,
|
compareToHash,
|
||||||
getEmailTokenAndHash,
|
getEmailTokenAndHash,
|
||||||
} from "../lib/password-utils";
|
} from "../lib/password-utils";
|
||||||
import {
|
import {
|
||||||
|
sendUpgradeRequestToAdmin,
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
} from "../lib/mail-utils";
|
} 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);
|
return res.send(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,7 +33,7 @@ apiRouter.use((req, res, next) => {
|
||||||
if (req.isAuthenticated()) {
|
if (req.isAuthenticated()) {
|
||||||
return next();
|
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);
|
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
|
* deletes a users own account
|
||||||
*/
|
*/
|
||||||
|
@ -94,7 +162,7 @@ apiRouter.delete("/user", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error("user not found");
|
if (!req.user) throw new Error("user not found");
|
||||||
await deleteLingdocsUser(req.user.userId);
|
await deleteLingdocsUser(req.user.userId);
|
||||||
sendResponse(res, { ok: true, message: "user delted" });
|
sendResponse(res, { ok: true, message: "user deleted" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { PassportStatic } from "passport";
|
import { PassportStatic } from "passport";
|
||||||
import {
|
import {
|
||||||
|
deleteLingdocsUser,
|
||||||
|
getAllLingdocsUsers,
|
||||||
getLingdocsUser,
|
getLingdocsUser,
|
||||||
updateLingdocsUser,
|
updateLingdocsUser,
|
||||||
} from "../lib/couch-db";
|
} from "../lib/couch-db";
|
||||||
|
@ -11,6 +13,10 @@ import {
|
||||||
compareToHash,
|
compareToHash,
|
||||||
getEmailTokenAndHash,
|
getEmailTokenAndHash,
|
||||||
} from "../lib/password-utils";
|
} from "../lib/password-utils";
|
||||||
|
import {
|
||||||
|
upgradeUser,
|
||||||
|
denyUserUpgradeRequest,
|
||||||
|
} from "../lib/user-utils";
|
||||||
import { validateReCaptcha } from "../lib/recaptcha";
|
import { validateReCaptcha } from "../lib/recaptcha";
|
||||||
import {
|
import {
|
||||||
getTimestamp,
|
getTimestamp,
|
||||||
|
@ -21,6 +27,7 @@ import {
|
||||||
} from "../lib/mail-utils";
|
} from "../lib/mail-utils";
|
||||||
import { outsideProviders } from "../middleware/setup-passport";
|
import { outsideProviders } from "../middleware/setup-passport";
|
||||||
import inProd from "../lib/inProd";
|
import inProd from "../lib/inProd";
|
||||||
|
import * as T from "../../../website/src/lib/account-types";
|
||||||
|
|
||||||
const authRouter = (passport: PassportStatic) => {
|
const authRouter = (passport: PassportStatic) => {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
@ -71,7 +78,7 @@ const authRouter = (passport: PassportStatic) => {
|
||||||
return res.render("login", { recaptcha: "fail", inProd });
|
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 (err) throw err;
|
||||||
if (!user && info.message === "email not found") {
|
if (!user && info.message === "email not found") {
|
||||||
return res.send({ ok: false, newSignup: true });
|
return res.send({ ok: false, newSignup: true });
|
||||||
|
@ -128,14 +135,61 @@ const authRouter = (passport: PassportStatic) => {
|
||||||
try {
|
try {
|
||||||
const { email, password, name } = req.body;
|
const { email, password, name } = req.body;
|
||||||
const existingUser = await getLingdocsUser("email", email);
|
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 });
|
const user = await createNewUser({ strategy: "local", email, passwordPlainText: password, name });
|
||||||
req.logIn(user, (err) => {
|
req.logIn(user, (err) => {
|
||||||
if (err) return next(err);
|
if (err) return next(err);
|
||||||
return res.send({ ok: true, user });
|
return res.send({ ok: true, user });
|
||||||
});
|
});
|
||||||
} catch(e) {
|
} 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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
|
@ -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>
|
|
@ -7,11 +7,17 @@
|
||||||
<title>Account · LingDocs</title>
|
<title>Account · LingDocs</title>
|
||||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
<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">
|
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous">
|
||||||
|
<script>
|
||||||
|
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container" style="max-width: 400px;">
|
<div class="container" style="max-width: 400px;">
|
||||||
<h2 class="mt-4 mb-4 text-center">LingDocs Account</h2>
|
<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">
|
<form method="POST" class="mb-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="form-label">Email:</label>
|
<label for="email" class="form-label">Email:</label>
|
||||||
|
@ -44,8 +50,20 @@
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="btn btn-primary">Update Profile</button>
|
<button type="submit" class="btn btn-primary">Update Profile</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
<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) { %>
|
<% if (user.email) { %>
|
||||||
<h4 class="mt-3">Password:</h4>
|
<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;">
|
<div id="password-change-form" style="display: none;">
|
||||||
<% if (user.password) { %>
|
<% if (user.password) { %>
|
||||||
|
@ -73,7 +91,7 @@
|
||||||
<div id="password-change-result" style="display: none;" class="alert alert-info mt-3 mb-4" role="alert">
|
<div id="password-change-result" style="display: none;" class="alert alert-info mt-3 mb-4" role="alert">
|
||||||
</div>
|
</div>
|
||||||
<% if (user.email) { %>
|
<% if (user.email) { %>
|
||||||
<div class="d-flex flex-row justify-content-between mt-4 mb-3">
|
<div class="d-flex flex-row justify-content-between mt-2 mb-1">
|
||||||
<button type="button" id="password-change-button" class="btn btn-secondary">
|
<button type="button" id="password-change-button" class="btn btn-secondary">
|
||||||
<% if (user.password) { %>
|
<% if (user.password) { %>
|
||||||
Change
|
Change
|
||||||
|
@ -85,15 +103,14 @@
|
||||||
<button type="button" style="display: none;" id="cancel-password-change-button" class="btn btn-light">Cancel</button>
|
<button type="button" style="display: none;" id="cancel-password-change-button" class="btn btn-light">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</form>
|
<h4 class="mt-3 mb-1">Linked Accounts <i class="fas fa-link ml-2"></i></h4>
|
||||||
<h4 class="mb-2">Linked Accounts:</h4>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<% if (user.google) { %>
|
<% if (user.google) { %>
|
||||||
<!-- TODO: MAKE THIS EMAIL THING SAFER! -->
|
<!-- 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>
|
<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">
|
<form action="/google/remove" method="POST">
|
||||||
<% if (removeProviderOption) { %>
|
<% 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>
|
</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>
|
<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">
|
<form action="/twitter/remove" method="POST">
|
||||||
<% if (removeProviderOption) { %>
|
<% 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>
|
</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>
|
<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">
|
<form action="/github/remove" method="POST">
|
||||||
<% if (removeProviderOption) { %>
|
<% 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>
|
</form>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
@ -138,6 +155,37 @@
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
<script>
|
<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() {
|
function clearPasswordForm() {
|
||||||
document.getElementById("oldPassword").value = "";
|
document.getElementById("oldPassword").value = "";
|
||||||
document.getElementById("password").value = "";
|
document.getElementById("password").value = "";
|
||||||
|
|
|
@ -7,8 +7,12 @@
|
||||||
"public": "public",
|
"public": "public",
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{
|
{
|
||||||
"source": "/authory",
|
"source": "/publishDictionary",
|
||||||
"function": "authory"
|
"function": "/publishDictionary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/submissions",
|
||||||
|
"function": "/submissions"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
|
|
||||||
|
|
|
@ -410,6 +410,16 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.55.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.55.tgz",
|
||||||
"integrity": "sha512-koZJ89uLZufDvToeWO5BrC4CR4OUfHnUz2qoPs/daQH6qq3IN62QFxCTZ+bKaCE0xaoCAJYE4AXre8AbghCrhg=="
|
"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": {
|
"@types/prop-types": {
|
||||||
"version": "15.7.3",
|
"version": "15.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
|
||||||
|
@ -540,6 +550,12 @@
|
||||||
"retry": "0.12.0"
|
"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": {
|
"axios": {
|
||||||
"version": "0.21.1",
|
"version": "0.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||||
|
@ -648,6 +664,15 @@
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true
|
"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": {
|
"compressible": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
|
@ -731,6 +756,12 @@
|
||||||
"ms": "2.1.2"
|
"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": {
|
"depd": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz",
|
||||||
"integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA=="
|
"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": {
|
"forwarded": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
|
||||||
|
|
|
@ -22,12 +22,14 @@
|
||||||
"firebase-functions": "^3.11.0",
|
"firebase-functions": "^3.11.0",
|
||||||
"google-spreadsheet": "^3.1.15",
|
"google-spreadsheet": "^3.1.15",
|
||||||
"nano": "^9.0.3",
|
"nano": "^9.0.3",
|
||||||
|
"node-fetch": "^2.6.1",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-bootstrap": "^1.5.1",
|
"react-bootstrap": "^1.5.1",
|
||||||
"react-dom": "^17.0.1"
|
"react-dom": "^17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
|
"@types/node-fetch": "^2.5.12",
|
||||||
"firebase-functions-test": "^0.2.0",
|
"firebase-functions-test": "^0.2.0",
|
||||||
"typescript": "^3.8.0"
|
"typescript": "^3.8.0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,89 +1,32 @@
|
||||||
import * as functions from "firebase-functions";
|
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 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);
|
export const publishDictionary = functions.runWith({
|
||||||
const usersDb = nano.db.use("_users");
|
timeoutSeconds: 60,
|
||||||
|
|
||||||
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"
|
memory: "2GB"
|
||||||
})
|
}).https.onRequest(lingdocsAuth(
|
||||||
.https.onRequest((req, res) => {
|
async (req, res: functions.Response<FT.PublishDictionaryResponse | FT.FunctionError>) => {
|
||||||
return cors({ origin: true })(req, res, () => {
|
if (req.user.level !== "editor") {
|
||||||
validateFirebaseIdToken(req, res, async () => {
|
res.status(403).send({ ok: false, error: "403 forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await publish();
|
const response = await publish();
|
||||||
return res.send(response);
|
res.send(response);
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
return res.status(500).send({
|
res.status(500).send({ ok: false, error: e.message });
|
||||||
error: error.toString(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
));
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: BETTER HANDLING OF EXPRESS MIDDLEWARE
|
export const submissions = functions.runWith({
|
||||||
|
|
||||||
export const submissions = functions
|
|
||||||
.region("europe-west1")
|
|
||||||
.runWith({
|
|
||||||
timeoutSeconds: 30,
|
timeoutSeconds: 30,
|
||||||
memory: "1GB"
|
memory: "1GB"
|
||||||
})
|
}).https.onRequest(lingdocsAuth(
|
||||||
.https.onRequest((req, res) => {
|
async (req, res: functions.Response<FT.SubmissionsResponse | FT.FunctionError>) => {
|
||||||
return cors({ origin: true })(req, res, () => {
|
|
||||||
validateFirebaseIdToken(req, res, async () => {
|
|
||||||
if (!Array.isArray(req.body)) {
|
if (!Array.isArray(req.body)) {
|
||||||
res.status(400).send({
|
res.status(400).send({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
@ -91,142 +34,13 @@ export const submissions = functions
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const suggestions = req.body as BT.SubmissionsRequest;
|
const suggestions = req.body as FT.SubmissionsRequest;
|
||||||
// @ts-ignore
|
|
||||||
const uid = req.user.uid as string;
|
|
||||||
const editor = await isEditor(req);
|
|
||||||
try {
|
try {
|
||||||
const response = await receiveSubmissions(suggestions, editor);
|
const response = await receiveSubmissions(suggestions, req.user.level === "editor");
|
||||||
// TODO: WARN IF ANY OF THE EDITS DIDN'T HAPPEN
|
// TODO: WARN IF ANY OF THE EDITS DIDN'T HAPPEN
|
||||||
res.send(response);
|
res.send(response);
|
||||||
return;
|
} catch (e) {
|
||||||
} catch (error) {
|
res.status(500).send({ ok: false, error: e.message });
|
||||||
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,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!user.docs.length) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return user.docs[0] as BT.CouchDbUser;
|
|
||||||
}
|
}
|
||||||
|
));
|
||||||
|
|
|
@ -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)}`;
|
|
||||||
}
|
|
|
@ -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 };
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ import {
|
||||||
// } from "./word-list-maker";
|
// } from "./word-list-maker";
|
||||||
import {
|
import {
|
||||||
PublishDictionaryResponse,
|
PublishDictionaryResponse,
|
||||||
} from "../../website/src/lib/backend-types";
|
} from "../../website/src/lib/functions-types";
|
||||||
import { Storage } from "@google-cloud/storage";
|
import { Storage } from "@google-cloud/storage";
|
||||||
const storage = new Storage({
|
const storage = new Storage({
|
||||||
projectId: "lingdocs",
|
projectId: "lingdocs",
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
|
import Nano from "nano";
|
||||||
import { GoogleSpreadsheet } from "google-spreadsheet";
|
import { GoogleSpreadsheet } from "google-spreadsheet";
|
||||||
import {
|
import {
|
||||||
dictionaryEntryTextFields,
|
dictionaryEntryTextFields,
|
||||||
dictionaryEntryBooleanFields,
|
dictionaryEntryBooleanFields,
|
||||||
dictionaryEntryNumberFields,
|
dictionaryEntryNumberFields,
|
||||||
} from "@lingdocs/pashto-inflector";
|
} 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";
|
import * as functions from "firebase-functions";
|
||||||
|
|
||||||
const fieldsForEdit = [
|
const fieldsForEdit = [
|
||||||
|
@ -13,11 +14,11 @@ const fieldsForEdit = [
|
||||||
...dictionaryEntryBooleanFields,
|
...dictionaryEntryBooleanFields,
|
||||||
].filter(field => !(["ts", "i"].includes(field)));
|
].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");
|
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);
|
const { edits, reviewTasks } = sortSubmissions(e);
|
||||||
|
|
||||||
// TODO: BETTER PROMISE MULTI-TASKING
|
// TODO: BETTER PROMISE MULTI-TASKING
|
||||||
|
@ -25,7 +26,6 @@ export async function receiveSubmissions(e: BT.SubmissionsRequest, editor: boole
|
||||||
// 2. Edit dictionary entries
|
// 2. Edit dictionary entries
|
||||||
// 3. Add new dictionary entries
|
// 3. Add new dictionary entries
|
||||||
|
|
||||||
|
|
||||||
if (reviewTasks.length) {
|
if (reviewTasks.length) {
|
||||||
const docs = reviewTasks.map((task) => ({
|
const docs = reviewTasks.map((task) => ({
|
||||||
...task,
|
...task,
|
||||||
|
@ -111,11 +111,11 @@ export async function receiveSubmissions(e: BT.SubmissionsRequest, editor: boole
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortedSubmissions = {
|
type SortedSubmissions = {
|
||||||
edits: BT.Edit[],
|
edits: FT.Edit[],
|
||||||
reviewTasks: BT.ReviewTask[],
|
reviewTasks: FT.ReviewTask[],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sortSubmissions(submissions: BT.Submission[]): SortedSubmissions {
|
export function sortSubmissions(submissions: FT.Submission[]): SortedSubmissions {
|
||||||
const base: SortedSubmissions = {
|
const base: SortedSubmissions = {
|
||||||
edits: [],
|
edits: [],
|
||||||
reviewTasks: [],
|
reviewTasks: [],
|
||||||
|
@ -131,12 +131,12 @@ export function sortSubmissions(submissions: BT.Submission[]): SortedSubmissions
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortedEdits = {
|
type SortedEdits = {
|
||||||
entryEdits: BT.EntryEdit[],
|
entryEdits: FT.EntryEdit[],
|
||||||
newEntries: BT.NewEntry[],
|
newEntries: FT.NewEntry[],
|
||||||
entryDeletions: BT.EntryDeletion[],
|
entryDeletions: FT.EntryDeletion[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortEdits(edits: BT.Edit[]): SortedEdits {
|
export function sortEdits(edits: FT.Edit[]): SortedEdits {
|
||||||
const base: SortedEdits = {
|
const base: SortedEdits = {
|
||||||
entryEdits: [],
|
entryEdits: [],
|
||||||
newEntries: [],
|
newEntries: [],
|
||||||
|
|
|
@ -18,11 +18,14 @@
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"cron": "^1.8.2",
|
"cron": "^1.8.2",
|
||||||
"dayjs": "^1.10.4",
|
"dayjs": "^1.10.4",
|
||||||
"firebase": "^8.3.0",
|
|
||||||
"lokijs": "^1.5.11",
|
"lokijs": "^1.5.11",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
|
"nano": "^9.0.3",
|
||||||
"node-sass": "^5.0.0",
|
"node-sass": "^5.0.0",
|
||||||
"papaparse": "^5.3.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",
|
"pbf": "^3.2.1",
|
||||||
"pouchdb": "^7.2.2",
|
"pouchdb": "^7.2.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
|
@ -89,6 +92,9 @@
|
||||||
"@types/react-helmet": "^6.1.0",
|
"@types/react-helmet": "^6.1.0",
|
||||||
"@types/react-image-crop": "^8.1.2",
|
"@types/react-image-crop": "^8.1.2",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@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",
|
"fake-indexeddb": "^3.1.2",
|
||||||
"history": "4",
|
"history": "4",
|
||||||
"jest-fetch-mock": "^3.0.3",
|
"jest-fetch-mock": "^3.0.3",
|
||||||
|
|
|
@ -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
|
|
|
@ -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 { Component } from "react";
|
||||||
import { defaultTextOptions } from "@lingdocs/pashto-inflector";
|
import { defaultTextOptions } from "@lingdocs/pashto-inflector";
|
||||||
import { withRouter, Route, RouteComponentProps, Link } from "react-router-dom";
|
import { withRouter, Route, RouteComponentProps, Link } from "react-router-dom";
|
||||||
|
@ -21,32 +24,37 @@ import ReviewTasks from "./screens/ReviewTasks";
|
||||||
import EntryEditor from "./screens/EntryEditor";
|
import EntryEditor from "./screens/EntryEditor";
|
||||||
import IsolatedEntry from "./screens/IsolatedEntry";
|
import IsolatedEntry from "./screens/IsolatedEntry";
|
||||||
import Wordlist from "./screens/Wordlist";
|
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 { 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 hitBottom from "./lib/hitBottom";
|
||||||
import getWordId from "./lib/get-word-id";
|
import getWordId from "./lib/get-word-id";
|
||||||
import { auth } from "./lib/firebase";
|
|
||||||
import { CronJob } from "cron";
|
import { CronJob } from "cron";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import {
|
import {
|
||||||
sendSubmissions,
|
sendSubmissions,
|
||||||
} from "./lib/submissions";
|
} from "./lib/submissions";
|
||||||
import {
|
import {
|
||||||
loadUserInfo,
|
getUser,
|
||||||
|
updateUserTextOptionsRecord,
|
||||||
} from "./lib/backend-calls";
|
} from "./lib/backend-calls";
|
||||||
import * as BT from "./lib/backend-types";
|
|
||||||
import {
|
import {
|
||||||
getWordlist,
|
getWordlist,
|
||||||
} from "./lib/wordlist-database";
|
} from "./lib/wordlist-database";
|
||||||
import {
|
import {
|
||||||
wordlistEnabled,
|
startLocalDbs,
|
||||||
} from "./lib/level-management";
|
stopLocalDbs,
|
||||||
import {
|
|
||||||
deInitializeLocalDb,
|
|
||||||
initializeLocalDb,
|
|
||||||
startLocalDbSync,
|
|
||||||
getLocalDbName,
|
|
||||||
getAllDocsLocalDb,
|
getAllDocsLocalDb,
|
||||||
} from "./lib/pouch-dbs";
|
} from "./lib/pouch-dbs";
|
||||||
import {
|
import {
|
||||||
|
@ -55,6 +63,7 @@ import {
|
||||||
import {
|
import {
|
||||||
textBadge,
|
textBadge,
|
||||||
} from "./lib/badges";
|
} from "./lib/badges";
|
||||||
|
import * as AT from "./lib/account-types";
|
||||||
import ReactGA from "react-ga";
|
import ReactGA from "react-ga";
|
||||||
// tslint:disable-next-line
|
// tslint:disable-next-line
|
||||||
import "@fortawesome/fontawesome-free/css/all.css";
|
import "@fortawesome/fontawesome-free/css/all.css";
|
||||||
|
@ -62,6 +71,7 @@ import "./custom-bootstrap.scss";
|
||||||
// tslint:disable-next-line: ordered-imports
|
// tslint:disable-next-line: ordered-imports
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { getTextOptions } from "./lib/get-text-options";
|
||||||
|
|
||||||
// to allow Moustrap key combos even when input fields are in focus
|
// to allow Moustrap key combos even when input fields are in focus
|
||||||
Mousetrap.prototype.stopCallback = function () {
|
Mousetrap.prototype.stopCallback = function () {
|
||||||
|
@ -89,13 +99,16 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
this.state = {
|
this.state = {
|
||||||
dictionaryStatus: "loading",
|
dictionaryStatus: "loading",
|
||||||
dictionaryInfo: undefined,
|
dictionaryInfo: undefined,
|
||||||
|
// TODO: Choose between the saved options and the options in the saved user
|
||||||
options: savedOptions ? savedOptions : {
|
options: savedOptions ? savedOptions : {
|
||||||
language: "Pashto",
|
language: "Pashto",
|
||||||
searchType: "fuzzy",
|
searchType: "fuzzy",
|
||||||
theme: /* istanbul ignore next */ (window.matchMedia &&
|
theme: /* istanbul ignore next */ (window.matchMedia &&
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
|
window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
|
||||||
|
textOptionsRecord: {
|
||||||
|
lastModified: Date.now() as AT.TimeStamp,
|
||||||
textOptions: defaultTextOptions,
|
textOptions: defaultTextOptions,
|
||||||
level: "basic",
|
},
|
||||||
wordlistMode: "browse",
|
wordlistMode: "browse",
|
||||||
wordlistReviewLanguage: "Pashto",
|
wordlistReviewLanguage: "Pashto",
|
||||||
wordlistReviewBadge: true,
|
wordlistReviewBadge: true,
|
||||||
|
@ -107,13 +120,15 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
results: [],
|
results: [],
|
||||||
wordlist: [],
|
wordlist: [],
|
||||||
reviewTasks: [],
|
reviewTasks: [],
|
||||||
|
user: readUser(),
|
||||||
};
|
};
|
||||||
this.handleOptionsUpdate = this.handleOptionsUpdate.bind(this);
|
this.handleOptionsUpdate = this.handleOptionsUpdate.bind(this);
|
||||||
|
this.handleTextOptionsUpdate = this.handleTextOptionsUpdate.bind(this);
|
||||||
this.handleSearchValueChange = this.handleSearchValueChange.bind(this);
|
this.handleSearchValueChange = this.handleSearchValueChange.bind(this);
|
||||||
this.handleIsolateEntry = this.handleIsolateEntry.bind(this);
|
this.handleIsolateEntry = this.handleIsolateEntry.bind(this);
|
||||||
this.handleScroll = this.handleScroll.bind(this);
|
this.handleScroll = this.handleScroll.bind(this);
|
||||||
this.handleGoBack = this.handleGoBack.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.handleRefreshWordlist = this.handleRefreshWordlist.bind(this);
|
||||||
this.handleRefreshReviewTasks = this.handleRefreshReviewTasks.bind(this);
|
this.handleRefreshReviewTasks = this.handleRefreshReviewTasks.bind(this);
|
||||||
this.handleDictionaryUpdate = this.handleDictionaryUpdate.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)) {
|
if (!possibleLandingPages.includes(this.props.location.pathname)) {
|
||||||
this.props.history.replace("/");
|
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);
|
ReactGA.pageview(window.location.pathname + window.location.search);
|
||||||
}
|
}
|
||||||
dictionary.initialize().then((r) => {
|
dictionary.initialize().then((r) => {
|
||||||
|
this.checkUserCronJob.start();
|
||||||
|
this.networkCronJob.start();
|
||||||
this.setState({
|
this.setState({
|
||||||
dictionaryStatus: "ready",
|
dictionaryStatus: "ready",
|
||||||
dictionaryInfo: r.dictionaryInfo,
|
dictionaryInfo: r.dictionaryInfo,
|
||||||
});
|
});
|
||||||
|
this.handleLoadUser();
|
||||||
// incase it took forever and timed out - might need to reinitialize the wordlist here ??
|
// incase it took forever and timed out - might need to reinitialize the wordlist here ??
|
||||||
if (wordlistEnabled(this.state)) {
|
if (this.state.user) {
|
||||||
initializeLocalDb("wordlist", this.handleRefreshWordlist, auth.currentUser ? auth.currentUser.uid : undefined);
|
startLocalDbs(this.state.user, { wordlist: this.handleRefreshWordlist, reviewTasks: this.handleRefreshReviewTasks });
|
||||||
}
|
|
||||||
if (this.state.options.level === "editor") {
|
|
||||||
initializeLocalDb("reviewTasks", this.handleRefreshReviewTasks);
|
|
||||||
}
|
}
|
||||||
if (this.props.location.pathname === "/word") {
|
if (this.props.location.pathname === "/word") {
|
||||||
const wordId = getWordId(this.props.location.search);
|
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) => {
|
Mousetrap.bind(["ctrl+down", "ctrl+up", "command+down", "command+up"], (e) => {
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
this.handleOptionsUpdate({ type: "toggleLanguage" });
|
this.handleOptionsUpdate({ type: "toggleLanguage" });
|
||||||
|
@ -218,7 +207,7 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
});
|
});
|
||||||
Mousetrap.bind(["ctrl+\\", "command+\\"], (e) => {
|
Mousetrap.bind(["ctrl+\\", "command+\\"], (e) => {
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
if (this.state.options.level === "basic") return;
|
if (this.state.user?.level === "basic") return;
|
||||||
if (this.props.location.pathname !== "/wordlist") {
|
if (this.props.location.pathname !== "/wordlist") {
|
||||||
this.props.history.push("/wordlist");
|
this.props.history.push("/wordlist");
|
||||||
} else {
|
} else {
|
||||||
|
@ -229,14 +218,9 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
window.removeEventListener("scroll", this.handleScroll);
|
window.removeEventListener("scroll", this.handleScroll);
|
||||||
this.unregisterAuthObserver();
|
this.checkUserCronJob.stop();
|
||||||
this.networkCronJob.stop();
|
this.networkCronJob.stop();
|
||||||
if (this.wordlistSync) {
|
stopLocalDbs();
|
||||||
this.wordlistSync.cancel();
|
|
||||||
}
|
|
||||||
if (this.reviewTastsSync) {
|
|
||||||
this.reviewTastsSync.cancel();
|
|
||||||
}
|
|
||||||
Mousetrap.unbind(["ctrl+down", "ctrl+up", "command+down", "command+up"]);
|
Mousetrap.unbind(["ctrl+down", "ctrl+up", "command+down", "command+up"]);
|
||||||
Mousetrap.unbind(["ctrl+b", "command+b"]);
|
Mousetrap.unbind(["ctrl+b", "command+b"]);
|
||||||
Mousetrap.unbind(["ctrl+\\", "command+\\"]);
|
Mousetrap.unbind(["ctrl+\\", "command+\\"]);
|
||||||
|
@ -244,7 +228,7 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: RouteComponentProps) {
|
public componentDidUpdate(prevProps: RouteComponentProps) {
|
||||||
if (this.props.location.pathname !== prevProps.location.pathname) {
|
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);
|
ReactGA.pageview(window.location.pathname + window.location.search);
|
||||||
}
|
}
|
||||||
if (this.props.location.pathname === "/") {
|
if (this.props.location.pathname === "/") {
|
||||||
|
@ -256,12 +240,12 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
page: 1,
|
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("/");
|
this.props.history.replace("/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (getWordId(this.props.location.search) !== getWordId(prevProps.location.search)) {
|
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);
|
ReactGA.pageview(window.location.pathname + window.location.search);
|
||||||
}
|
}
|
||||||
const wordId = getWordId(this.props.location.search);
|
const wordId = getWordId(this.props.location.search);
|
||||||
|
@ -277,54 +261,42 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private unregisterAuthObserver() {
|
private async handleLoadUser(): Promise<void> {
|
||||||
// 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> {
|
|
||||||
try {
|
try {
|
||||||
const userInfo = await loadUserInfo();
|
const prevUser = this.state.user;
|
||||||
const differentUserInfoLevel = userInfo && (userInfo.level !== this.state.options.level);
|
const userOnServer = await getUser();
|
||||||
const needToDowngrade = (!userInfo && wordlistEnabled(this.state));
|
if (userOnServer === "offline") return;
|
||||||
if (differentUserInfoLevel || needToDowngrade) {
|
if (userOnServer) sendSubmissions();
|
||||||
this.handleOptionsUpdate({
|
if (!userOnServer) {
|
||||||
type: "changeUserLevel",
|
this.setState({ user: undefined });
|
||||||
payload: userInfo ? userInfo.level : "basic",
|
saveUser(undefined);
|
||||||
});
|
return;
|
||||||
}
|
}
|
||||||
if (!userInfo) return undefined;
|
const { userTextOptionsRecord, serverOptionsAreNewer } = resolveTextOptions(userOnServer, prevUser, this.state.options.textOptionsRecord);
|
||||||
// only sync wordlist for upgraded accounts
|
const user: AT.LingdocsUser = {
|
||||||
if (userInfo && wordlistEnabled(userInfo.level)) {
|
...userOnServer,
|
||||||
// TODO: GO OVER THIS HORRENDOUS BLOCK
|
userTextOptionsRecord,
|
||||||
if (userInfo.level === "editor") {
|
};
|
||||||
initializeLocalDb("reviewTasks", this.handleRefreshReviewTasks);
|
this.setState({ user });
|
||||||
if (!this.reviewTastsSync) {
|
saveUser(user);
|
||||||
this.reviewTastsSync = startLocalDbSync("reviewTasks", { name: userInfo.name, password: userInfo.userdbPassword });
|
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();
|
||||||
}
|
}
|
||||||
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 });
|
|
||||||
}
|
|
||||||
return userInfo;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("error checking user level", 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") {
|
if (action.type === "changeTheme") {
|
||||||
document.documentElement.setAttribute("data-theme", action.payload);
|
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);
|
const options = optionsReducer(this.state.options, action);
|
||||||
saveOptions(options);
|
saveOptions(options);
|
||||||
if (action.type === "toggleLanguage" || action.type === "toggleSearchType") {
|
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) {
|
private handleSearchValueChange(searchValue: string) {
|
||||||
if (this.state.dictionaryStatus !== "ready") return;
|
if (this.state.dictionaryStatus !== "ready") return;
|
||||||
if (searchValue === "") {
|
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 * * * *", () => {
|
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();
|
this.handleDictionaryUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -445,7 +438,7 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
paddingBottom: "60px",
|
paddingBottom: "60px",
|
||||||
}}>
|
}}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>LingDocs Pashto Dictionary</title>
|
<title>LingDocs Dictionary - Dev Branch</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
{this.state.options.searchBarPosition === "top" && <SearchBar
|
{this.state.options.searchBarPosition === "top" && <SearchBar
|
||||||
state={this.state}
|
state={this.state}
|
||||||
|
@ -459,11 +452,11 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
<>
|
<>
|
||||||
<Route path="/" exact>
|
<Route path="/" exact>
|
||||||
<div className="text-center mt-4">
|
<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">
|
{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 className="mb-3"><span className="fa fa-book mr-2" ></span> Alphabetical browsing mode</div>
|
||||||
</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>
|
<div className="mb-3">Editor priveleges active</div>
|
||||||
<Link to="/edit">
|
<Link to="/edit">
|
||||||
<button className="btn btn-secondary">New Entry</button>
|
<button className="btn btn-secondary">New Entry</button>
|
||||||
|
@ -478,7 +471,12 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
<About state={this.state} />
|
<About state={this.state} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<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>
|
||||||
<Route path="/search">
|
<Route path="/search">
|
||||||
<Results state={this.state} isolateEntry={this.handleIsolateEntry} />
|
<Results state={this.state} isolateEntry={this.handleIsolateEntry} />
|
||||||
|
@ -492,10 +490,7 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
}
|
}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/account">
|
<Route path="/account">
|
||||||
<Account level={this.state.options.level} loadUserInfo={this.handleLoadUserInfo} handleSignOut={(() => {
|
<Account user={this.state.user} loadUser={this.handleLoadUser} />
|
||||||
this.props.history.replace("/");
|
|
||||||
auth.signOut();
|
|
||||||
})} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/word">
|
<Route path="/word">
|
||||||
<IsolatedEntry
|
<IsolatedEntry
|
||||||
|
@ -504,21 +499,21 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
isolateEntry={this.handleIsolateEntry}
|
isolateEntry={this.handleIsolateEntry}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
{wordlistEnabled(this.state) && <Route path="/wordlist">
|
{wordlistEnabled(this.state.user) && <Route path="/wordlist">
|
||||||
<Wordlist
|
<Wordlist
|
||||||
state={this.state}
|
state={this.state}
|
||||||
isolateEntry={this.handleIsolateEntry}
|
isolateEntry={this.handleIsolateEntry}
|
||||||
optionsDispatch={this.handleOptionsUpdate}
|
optionsDispatch={this.handleOptionsUpdate}
|
||||||
/>
|
/>
|
||||||
</Route>}
|
</Route>}
|
||||||
{this.state.options.level === "editor" && <Route path="/edit">
|
{this.state.user?.level === "editor" && <Route path="/edit">
|
||||||
<EntryEditor
|
<EntryEditor
|
||||||
state={this.state}
|
state={this.state}
|
||||||
dictionary={dictionary}
|
dictionary={dictionary}
|
||||||
searchParams={new URLSearchParams(this.props.history.location.search)}
|
searchParams={new URLSearchParams(this.props.history.location.search)}
|
||||||
/>
|
/>
|
||||||
</Route>}
|
</Route>}
|
||||||
{this.state.options.level === "editor" && <Route path="/review-tasks">
|
{this.state.user?.level === "editor" && <Route path="/review-tasks">
|
||||||
<ReviewTasks state={this.state} />
|
<ReviewTasks state={this.state} />
|
||||||
</Route>}
|
</Route>}
|
||||||
</>
|
</>
|
||||||
|
@ -534,15 +529,15 @@ class App extends Component<RouteComponentProps, State> {
|
||||||
<div className="buttons-footer">
|
<div className="buttons-footer">
|
||||||
<BottomNavItem label="About" icon="info-circle" page="/about" />
|
<BottomNavItem label="About" icon="info-circle" page="/about" />
|
||||||
<BottomNavItem label="Settings" icon="cog" page="/settings" />
|
<BottomNavItem label="Settings" icon="cog" page="/settings" />
|
||||||
<BottomNavItem label={auth.currentUser ? "Account" : "Sign In"} icon="user" page="/account" />
|
<BottomNavItem label={this.state.user ? "Account" : "Sign In"} icon="user" page="/account" />
|
||||||
{wordlistEnabled(this.state) &&
|
{wordlistEnabled(this.state.user) &&
|
||||||
<BottomNavItem
|
<BottomNavItem
|
||||||
label={`Wordlist ${this.state.options.wordlistReviewBadge ? textBadge(forReview(this.state.wordlist).length) : ""}`}
|
label={`Wordlist ${this.state.options.wordlistReviewBadge ? textBadge(forReview(this.state.wordlist).length) : ""}`}
|
||||||
icon="list"
|
icon="list"
|
||||||
page="/wordlist"
|
page="/wordlist"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{this.state.options.level === "editor" &&
|
{this.state.user?.level === "editor" &&
|
||||||
<BottomNavItem
|
<BottomNavItem
|
||||||
label={`Tasks ${textBadge(this.state.reviewTasks.length)}`}
|
label={`Tasks ${textBadge(this.state.reviewTasks.length)}`}
|
||||||
icon="edit"
|
icon="edit"
|
||||||
|
|
|
@ -22,7 +22,7 @@ ReactDOM.render(
|
||||||
document.getElementById('root')
|
document.getElementById('root')
|
||||||
);
|
);
|
||||||
|
|
||||||
serviceWorkerRegistration.register();
|
serviceWorkerRegistration.unregister();
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
// If you want to start measuring performance in your app, pass a function
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -1,56 +1,73 @@
|
||||||
import { auth } from "./firebase";
|
import * as FT from "./functions-types";
|
||||||
import * as BT from "./backend-types";
|
import * as AT from "./account-types";
|
||||||
|
|
||||||
const functionsBaseUrl = // process.env.NODE_ENV === "development"
|
type Service = "account" | "functions";
|
||||||
// "http://127.0.0.1:5001/lingdocs/europe-west1/"
|
|
||||||
"https://europe-west1-lingdocs.cloudfunctions.net/";
|
|
||||||
|
|
||||||
|
const baseUrl: Record<Service, string> = {
|
||||||
|
account: "https://account.lingdocs.com/api/",
|
||||||
|
functions: "https://functions.lingdocs.com/",
|
||||||
|
};
|
||||||
|
|
||||||
export async function publishDictionary(): Promise<BT.PublishDictionaryResponse> {
|
// FUNCTIONS CALLS - MUST BE RE-ROUTED THROUGH FIREBASE HOSTING IN ../../../firebase.json
|
||||||
const res = await tokenFetch("publishDictionary");
|
export async function publishDictionary(): Promise<FT.PublishDictionaryResponse | FT.FunctionError> {
|
||||||
if (!res) {
|
return await myFetch("functions", "publishDictionary") as FT.PublishDictionaryResponse | FT.FunctionError;
|
||||||
throw new Error("Connection error/offline");
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upgradeAccount(password: string): Promise<BT.UpgradeUserResponse> {
|
export async function postSubmissions(submissions: FT.SubmissionsRequest): Promise<FT.SubmissionsResponse> {
|
||||||
const res = await tokenFetch("upgradeUser", "POST", { password });
|
return await myFetch("functions", "submissions", "POST", submissions) as FT.SubmissionsResponse;
|
||||||
if (!res) {
|
|
||||||
throw new Error("Connection error/offline");
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postSubmissions(submissions: BT.SubmissionsRequest): Promise<BT.SubmissionsResponse> {
|
// ACCOUNT CALLS
|
||||||
return await tokenFetch("submissions", "POST", submissions) as BT.SubmissionsResponse;
|
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> {
|
export async function upgradeToStudentRequest(): Promise<AT.APIResponse> {
|
||||||
const res = await tokenFetch("getUserInfo", "GET") as BT.GetUserInfoResponse;
|
return await myFetch("account", "user/upgradeToStudentRequest", "POST") as AT.APIResponse;
|
||||||
return "user" in res ? res.user : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: HARD TYPING OF THIS WITH THE subUrl and return values etc?
|
export async function updateUserTextOptionsRecord(userTextOptionsRecord: AT.UserTextOptionsRecord): Promise<AT.UpdateUserTextOptionsRecordResponse> {
|
||||||
async function tokenFetch(subUrl: string, method?: "GET" | "POST", body?: any): Promise<any> {
|
const response = await myFetch("account", "user/userTextOptionsRecord", "PUT", { userTextOptionsRecord }) as AT.UpdateUserTextOptionsRecordResponse;
|
||||||
if (!auth.currentUser) {
|
return response;
|
||||||
throw new Error("not signed in");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function signOut() {
|
||||||
try {
|
try {
|
||||||
const token = await auth.currentUser.getIdToken();
|
await myFetch("account", "sign-out", "POST");
|
||||||
const response = await fetch(`${functionsBaseUrl}${subUrl}`, {
|
} 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,
|
method,
|
||||||
|
credentials: "include",
|
||||||
|
...body ? {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${token}`,
|
|
||||||
},
|
},
|
||||||
...body ? {
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
} : {},
|
} : {},
|
||||||
});
|
});
|
||||||
return await response.json();
|
return await response.json() as AT.APIResponse;
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { fuzzifyPashto } from "./fuzzify-pashto/fuzzify-pashto";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import relevancy from "relevancy";
|
import relevancy from "relevancy";
|
||||||
import { makeAWeeBitFuzzy } from "./wee-bit-fuzzy";
|
import { makeAWeeBitFuzzy } from "./wee-bit-fuzzy";
|
||||||
|
import { getTextOptions } from "./get-text-options";
|
||||||
|
|
||||||
// const dictionaryBaseUrl = "https://storage.googleapis.com/lingdocs/";
|
// const dictionaryBaseUrl = "https://storage.googleapis.com/lingdocs/";
|
||||||
const dictionaryUrl = `https://storage.googleapis.com/lingdocs/dictionary`;
|
const dictionaryUrl = `https://storage.googleapis.com/lingdocs/dictionary`;
|
||||||
|
@ -353,7 +354,7 @@ export const dictionary: DictionaryAPI = {
|
||||||
search: function(state: State): Types.DictionaryEntry[] {
|
search: function(state: State): Types.DictionaryEntry[] {
|
||||||
const searchString = convertSpelling(
|
const searchString = convertSpelling(
|
||||||
state.searchValue,
|
state.searchValue,
|
||||||
state.options.textOptions.spelling,
|
getTextOptions(state).spelling,
|
||||||
);
|
);
|
||||||
if (state.searchValue === "") {
|
if (state.searchValue === "") {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
@ -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();
|
|
||||||
|
|
|
@ -7,6 +7,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Types as T } from "@lingdocs/pashto-inflector";
|
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 = {
|
export type PublishDictionaryResponse = {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
@ -16,20 +21,18 @@ export type PublishDictionaryResponse = {
|
||||||
errors: T.DictionaryEntryError[],
|
errors: T.DictionaryEntryError[],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserInfo = {
|
|
||||||
uid: string,
|
|
||||||
email: string | null,
|
|
||||||
displayName: string | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Submission = Edit | ReviewTask;
|
export type Submission = Edit | ReviewTask;
|
||||||
|
|
||||||
export type Edit = EntryEdit | NewEntry | EntryDeletion
|
export type Edit = EntryEdit | NewEntry | EntryDeletion
|
||||||
|
|
||||||
export type SubmissionBase = {
|
export type SubmissionBase = {
|
||||||
sTs: number,
|
|
||||||
user: UserInfo,
|
|
||||||
_id: string,
|
_id: string,
|
||||||
|
sTs: number,
|
||||||
|
user: {
|
||||||
|
userId: AT.UUID,
|
||||||
|
name: string,
|
||||||
|
email: string,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReviewTask = Issue | EditSuggestion | EntrySuggestion;
|
export type ReviewTask = Issue | EditSuggestion | EntrySuggestion;
|
||||||
|
@ -73,36 +76,3 @@ export type SubmissionsResponse = {
|
||||||
message: string,
|
message: string,
|
||||||
submissions: Submission[],
|
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",
|
|
||||||
};
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Types as T } from "@lingdocs/pashto-inflector";
|
||||||
|
|
||||||
|
export function getTextOptions(state: State): T.TextOptions {
|
||||||
|
return state.options.textOptionsRecord.textOptions;
|
||||||
|
}
|
|
@ -1,14 +1,6 @@
|
||||||
/**
|
import type { LingdocsUser } from "./account-types";
|
||||||
* 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 function wordlistEnabled(state: State | UserLevel): boolean {
|
export function wordlistEnabled(user: LingdocsUser | undefined): boolean {
|
||||||
const level = (typeof state === "string")
|
if (!user) return false;
|
||||||
? state
|
return user.level !== "basic";
|
||||||
: state.options.level;
|
|
||||||
return level !== "basic";
|
|
||||||
}
|
}
|
|
@ -6,7 +6,7 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { saveOptions, readOptions, optionsLocalStorageName } from "./options-storage";
|
import { saveOptions, readOptions, optionsLocalStorageName } from "./local-storage";
|
||||||
import {
|
import {
|
||||||
defaultTextOptions,
|
defaultTextOptions,
|
||||||
} from "@lingdocs/pashto-inflector";
|
} from "@lingdocs/pashto-inflector";
|
||||||
|
@ -16,7 +16,6 @@ const optionsStub: Options = {
|
||||||
searchType: "fuzzy",
|
searchType: "fuzzy",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
textOptions: defaultTextOptions,
|
textOptions: defaultTextOptions,
|
||||||
level: "student",
|
|
||||||
wordlistMode: "browse",
|
wordlistMode: "browse",
|
||||||
wordlistReviewLanguage: "Pashto",
|
wordlistReviewLanguage: "Pashto",
|
||||||
wordlistReviewBadge: true,
|
wordlistReviewBadge: true,
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
|
@ -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");
|
|
||||||
})
|
|
|
@ -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") {
|
if (action.type === "toggleLanguage") {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
|
@ -23,12 +26,6 @@ function optionsReducer(options: Options, action: OptionsAction): Options {
|
||||||
searchBarPosition: action.payload,
|
searchBarPosition: action.payload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === "changeUserLevel") {
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
level: action.payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action.type === "changeWordlistMode") {
|
if (action.type === "changeWordlistMode") {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
|
@ -47,52 +44,81 @@ function optionsReducer(options: Options, action: OptionsAction): Options {
|
||||||
wordlistReviewLanguage: action.payload,
|
wordlistReviewLanguage: action.payload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === "changePTextSize") {
|
if (action.type === "updateTextOptionsRecord") {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
textOptions: {
|
textOptionsRecord: action.payload,
|
||||||
...options.textOptions,
|
};
|
||||||
|
}
|
||||||
|
throw new Error("action type not recognized in options reducer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textOptionsReducer(textOptions: IT.TextOptions, action: TextOptionsAction): IT.TextOptions {
|
||||||
|
if (action.type === "changePTextSize") {
|
||||||
|
return {
|
||||||
|
...textOptions,
|
||||||
pTextSize: action.payload,
|
pTextSize: action.payload,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === "changeSpelling") {
|
if (action.type === "changeSpelling") {
|
||||||
return {
|
return {
|
||||||
...options,
|
...textOptions,
|
||||||
textOptions: {
|
|
||||||
...options.textOptions,
|
|
||||||
spelling: action.payload,
|
spelling: action.payload,
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === "changePhonetics") {
|
if (action.type === "changePhonetics") {
|
||||||
return {
|
return {
|
||||||
...options,
|
...textOptions,
|
||||||
textOptions: {
|
|
||||||
...options.textOptions,
|
|
||||||
phonetics: action.payload,
|
phonetics: action.payload,
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === "changeDialect") {
|
if (action.type === "changeDialect") {
|
||||||
return {
|
return {
|
||||||
...options,
|
...textOptions,
|
||||||
textOptions: {
|
|
||||||
...options.textOptions,
|
|
||||||
dialect: action.payload,
|
dialect: action.payload,
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === "changeDiacritics") {
|
if (action.type === "changeDiacritics") {
|
||||||
return {
|
return {
|
||||||
...options,
|
...textOptions,
|
||||||
textOptions: {
|
|
||||||
...options.textOptions,
|
|
||||||
diacritics: action.payload,
|
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),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error("action type not recognized in reducer");
|
// 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default optionsReducer;
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,73 +1,113 @@
|
||||||
import PouchDB from "pouchdb";
|
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 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 DbInput = {
|
||||||
type: "wordlist",
|
type: "wordlist",
|
||||||
doc: WordlistWord,
|
doc: WordlistWord,
|
||||||
} | {
|
} | {
|
||||||
type: "submissions",
|
type: "submissions",
|
||||||
doc: BT.Submission,
|
doc: FT.Submission,
|
||||||
} | {
|
} | {
|
||||||
type: "reviewTasks",
|
type: "reviewTasks",
|
||||||
doc: BT.ReviewTask,
|
doc: FT.ReviewTask,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dbs: Record<LocalDbType, LocalDb> = {
|
const dbs: DBS = {
|
||||||
/* for anyone logged in - for edits/suggestions submissions */
|
/* for anyone logged in - for edits/suggestions submissions */
|
||||||
submissions: null,
|
submissions: undefined,
|
||||||
/* for students and above - personal wordlist database */
|
/* for students and above - personal wordlist database */
|
||||||
wordlist: null,
|
wordlist: undefined,
|
||||||
/* for editors only - edits/suggestions (submissions) for review */
|
/* for editors only - edits/suggestions (submissions) for review */
|
||||||
reviewTasks: null,
|
reviewTasks: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function initializeLocalDb(type: LocalDbType, refresh: () => void, uid?: string | undefined) {
|
export function startLocalDbs(user: AT.LingdocsUser, refreshFns: { wordlist: () => void, reviewTasks: () => void }) {
|
||||||
const name = type === "wordlist"
|
if (user.level === "basic") {
|
||||||
? `userdb-${uid? stringToHex(uid) : "guest"}`
|
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"
|
: type === "submissions"
|
||||||
? "submissions"
|
? "submissions"
|
||||||
: "review-tasks";
|
: (type === "wordlist" && "wordlistDbName" in user)
|
||||||
|
? user.wordlistDbName
|
||||||
|
: "";
|
||||||
|
const password = "couchDbPassword" in user ? user.couchDbPassword : "";
|
||||||
const db = dbs[type];
|
const db = dbs[type];
|
||||||
// only initialize the db if it doesn't exist or if it has a different name
|
// only initialize the db if it doesn't exist or if it has a different name
|
||||||
if ((!db) || (db.db?.name !== name)) {
|
if ((!db) || (db.db?.name !== name)) {
|
||||||
|
if (type === "submissions") {
|
||||||
dbs[type] = {
|
dbs[type] = {
|
||||||
db: new PouchDB(name),
|
|
||||||
refresh,
|
refresh,
|
||||||
|
db: new PouchDB(name),
|
||||||
};
|
};
|
||||||
refresh();
|
} else {
|
||||||
}
|
dbs[type]?.sync.cancel();
|
||||||
}
|
const db = new PouchDB(name);
|
||||||
|
dbs[type] = {
|
||||||
export function getLocalDbName(type: LocalDbType) {
|
db,
|
||||||
return dbs[type]?.db.name;
|
refresh,
|
||||||
}
|
sync: db.sync(
|
||||||
|
`https://${user.userId}:${password}@couch.lingdocs.com/${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 },
|
{ live: true, retry: true },
|
||||||
).on("change", (info) => {
|
).on("change", (info) => {
|
||||||
if (info.direction === "pull") {
|
if (info.direction === "pull") {
|
||||||
localDb.refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
}).on("error", (error) => {
|
}).on("error", (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
}),
|
||||||
return sync;
|
};
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addToLocalDb({ type, doc }: DbInput) {
|
export async function addToLocalDb({ type, doc }: DbInput) {
|
||||||
|
@ -99,13 +139,13 @@ export async function updateLocalDbDoc({ type, doc }: DbInput, id: string) {
|
||||||
return updated;
|
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: "wordlist", limit?: number): Promise<WordlistWordDoc[]>;
|
||||||
export async function getAllDocsLocalDb(type: "reviewTasks", limit?: number): Promise<BT.ReviewTask[]>
|
export async function getAllDocsLocalDb(type: "reviewTasks", limit?: number): Promise<FT.ReviewTask[]>
|
||||||
export async function getAllDocsLocalDb(type: LocalDbType, limit?: number): Promise<BT.Submission[] | WordlistWordDoc[] | BT.ReviewTask[]> {
|
export async function getAllDocsLocalDb(type: LocalDbType, limit?: number): Promise<FT.Submission[] | WordlistWordDoc[] | FT.ReviewTask[]> {
|
||||||
const localDb = dbs[type];
|
const localDb = dbs[type];
|
||||||
if (!localDb) {
|
if (!localDb) {
|
||||||
throw new Error(`unable to get all docs from ${type} database - not initialized`);
|
return [];
|
||||||
}
|
}
|
||||||
const descending = type !== "reviewTasks";
|
const descending = type !== "reviewTasks";
|
||||||
const result = await localDb.db.allDocs({
|
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;
|
const docs = result.rows.map((row) => row.doc) as unknown;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "submissions":
|
case "submissions":
|
||||||
return docs as BT.Submission[];
|
return docs as FT.Submission[];
|
||||||
case "wordlist":
|
case "wordlist":
|
||||||
return docs as WordlistWordDoc[];
|
return docs as WordlistWordDoc[];
|
||||||
case "reviewTasks":
|
case "reviewTasks":
|
||||||
return docs as BT.ReviewTask[];
|
return docs as FT.ReviewTask[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,12 +199,3 @@ export async function deleteFromLocalDb(type: LocalDbType, id: string | string[]
|
||||||
}
|
}
|
||||||
localDb.refresh();
|
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('');
|
|
||||||
}
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ function fFuzzy(f: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchAllInflections(allDocs: T.DictionaryEntry[], searchValue: string): { entry: T.DictionaryEntry, results: InflectionSearchResult[] }[] {
|
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 beg = fFuzzy(searchValue.slice(0, 2));
|
||||||
const preSearchFun = isPashtoScript(searchValue)
|
const preSearchFun = isPashtoScript(searchValue)
|
||||||
? (ps: T.PsString) => ps.p.slice(0, 2) === beg
|
? (ps: T.PsString) => ps.p.slice(0, 2) === beg
|
||||||
|
@ -37,7 +37,7 @@ export function searchAllInflections(allDocs: T.DictionaryEntry[], searchValue:
|
||||||
const searchFun = isPashtoScript(searchValue)
|
const searchFun = isPashtoScript(searchValue)
|
||||||
? (ps: T.PsString) => ps.p === searchValue
|
? (ps: T.PsString) => ps.p === searchValue
|
||||||
: (ps: T.PsString) => !!ps.f.match(fRegex);
|
: (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 results = allDocs.reduce((all: { entry: T.DictionaryEntry, results: InflectionSearchResult[] }[], entry) => {
|
||||||
const type = isNounAdjOrVerb(entry);
|
const type = isNounAdjOrVerb(entry);
|
||||||
if (entry.c && type === "verb") {
|
if (entry.c && type === "verb") {
|
||||||
|
@ -74,6 +74,6 @@ export function searchAllInflections(allDocs: T.DictionaryEntry[], searchValue:
|
||||||
}
|
}
|
||||||
return all;
|
return all;
|
||||||
}, []);
|
}, []);
|
||||||
console.timeEnd(timerLabel);
|
// console.timeEnd(timerLabel);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
|
@ -1,28 +1,22 @@
|
||||||
import * as BT from "./backend-types";
|
import * as FT from "./functions-types";
|
||||||
import { auth } from "./firebase";
|
import * as AT from "./account-types";
|
||||||
import {
|
import {
|
||||||
postSubmissions,
|
postSubmissions,
|
||||||
} from "./backend-calls";
|
} from "./backend-calls";
|
||||||
import {
|
import {
|
||||||
initializeLocalDb,
|
|
||||||
addToLocalDb,
|
addToLocalDb,
|
||||||
getAllDocsLocalDb,
|
getAllDocsLocalDb,
|
||||||
deleteFromLocalDb,
|
deleteFromLocalDb,
|
||||||
} from "./pouch-dbs";
|
} from "./pouch-dbs";
|
||||||
|
|
||||||
initializeLocalDb("submissions", () => null);
|
export function submissionBase(user: AT.LingdocsUser): FT.SubmissionBase {
|
||||||
|
|
||||||
export function submissionBase(): BT.SubmissionBase {
|
|
||||||
if (!auth.currentUser) {
|
|
||||||
throw new Error("not signed in");
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
sTs: Date.now(),
|
sTs: Date.now(),
|
||||||
_id: new Date().toJSON(),
|
_id: new Date().toJSON(),
|
||||||
user: {
|
user: {
|
||||||
uid: auth.currentUser.uid,
|
name: user.name,
|
||||||
email: auth.currentUser.email,
|
email: user.email || "",
|
||||||
displayName: auth.currentUser.displayName,
|
userId: user.userId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -40,16 +34,19 @@ export async function sendSubmissions() {
|
||||||
}));
|
}));
|
||||||
const res = await postSubmissions(revRemoved);
|
const res = await postSubmissions(revRemoved);
|
||||||
// delete the submissions that were received from the local submissions db
|
// delete the submissions that were received from the local submissions db
|
||||||
|
console.log(res);
|
||||||
|
if (res.submissions) {
|
||||||
res.submissions.forEach((submission) => {
|
res.submissions.forEach((submission) => {
|
||||||
deleteFromLocalDb("submissions", submission._id);
|
deleteFromLocalDb("submissions", submission._id);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("error posting submissions", err);
|
console.error("error posting submissions", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addSubmission(submission: BT.Submission, level: BT.UserLevel) {
|
export async function addSubmission(submission: FT.Submission, user: AT.LingdocsUser) {
|
||||||
if (level === "editor" && (submission.type === "issue" || submission.type === "entry suggestion" || submission.type === "edit suggestion")) {
|
if (user.level === "editor" && (submission.type === "issue" || submission.type === "entry suggestion" || submission.type === "edit suggestion")) {
|
||||||
await addToLocalDb({ type: "reviewTasks", doc: submission })
|
await addToLocalDb({ type: "reviewTasks", doc: submission })
|
||||||
} else {
|
} else {
|
||||||
await addToLocalDb({ type: "submissions", doc: submission });
|
await addToLocalDb({ type: "submissions", doc: submission });
|
||||||
|
|
|
@ -1,72 +1,74 @@
|
||||||
import { useState, useEffect } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
import { Modal, Button } from "react-bootstrap";
|
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 {
|
import {
|
||||||
upgradeAccount,
|
upgradeAccount,
|
||||||
|
signOut,
|
||||||
publishDictionary,
|
publishDictionary,
|
||||||
|
upgradeToStudentRequest,
|
||||||
} from "../lib/backend-calls";
|
} from "../lib/backend-calls";
|
||||||
import LoadingElipses from "../components/LoadingElipses";
|
import LoadingElipses from "../components/LoadingElipses";
|
||||||
import { Helmet } from "react-helmet";
|
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 => {
|
const capitalize = (s: string): string => {
|
||||||
// if (!s) return "";
|
// if (!s) return "";
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Account = ({ handleSignOut, level, loadUserInfo }: {
|
let popupRef: Window | null = null;
|
||||||
handleSignOut: () => void,
|
|
||||||
loadUserInfo: () => void,
|
const Account = ({ user, loadUser }: { user: AT.LingdocsUser | undefined, loadUser: () => void }) => {
|
||||||
level: UserLevel,
|
|
||||||
}) => {
|
|
||||||
const [showingDeleteConfirmation, setShowingDeleteConfirmation] = useState<boolean>(false);
|
|
||||||
const [showingUpgradePrompt, setShowingUpgradePrompt] = useState<boolean>(false);
|
const [showingUpgradePrompt, setShowingUpgradePrompt] = useState<boolean>(false);
|
||||||
const [upgradePassword, setUpgradePassword] = useState<string>("");
|
const [upgradePassword, setUpgradePassword] = useState<string>("");
|
||||||
const [upgradeError, setUpgradeError] = 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 [waiting, setWaiting] = useState<boolean>(false);
|
||||||
const [publishingStatus, setPublishingStatus] = useState<undefined | "publishing" | any>(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
setShowingDeleteConfirmation(false);
|
|
||||||
setShowingUpgradePrompt(false);
|
setShowingUpgradePrompt(false);
|
||||||
setUpgradePassword("");
|
|
||||||
setUpgradeError("");
|
setUpgradeError("");
|
||||||
setWaiting(false);
|
setWaiting(false);
|
||||||
|
window.addEventListener("message", handleIncomingMessage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleIncomingMessage);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
// TODO put the account url in an imported constant
|
||||||
setEmailVerification((user && user.emailVerified) ? "verified" : "unverified");
|
function handleIncomingMessage(event: MessageEvent<any>) {
|
||||||
}, [user]);
|
if (event.origin === "https://account.lingdocs.com" && event.data === "signed in" && popupRef) {
|
||||||
function handleDelete() {
|
loadUser();
|
||||||
auth.currentUser?.delete().then(() => {
|
popupRef.close();
|
||||||
setAccountDeleteError("");
|
}
|
||||||
setShowingDeleteConfirmation(false);
|
}
|
||||||
setAccountDeleted(true);
|
async function handleSignOut() {
|
||||||
}).catch((err) => {
|
await signOut();
|
||||||
console.error(err);
|
loadUser();
|
||||||
setAccountDeleteError(err.message);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function closeUpgrade() {
|
function closeUpgrade() {
|
||||||
setShowingUpgradePrompt(false);
|
setShowingUpgradePrompt(false);
|
||||||
setUpgradePassword("");
|
setUpgradePassword("");
|
||||||
setUpgradeError("");
|
setUpgradeError("");
|
||||||
}
|
}
|
||||||
function closeUpdateEmail() {
|
async function handleUpgradeRequest() {
|
||||||
setShowingUpdateEmail(false);
|
setUpgradeError("");
|
||||||
setNewEmail("");
|
setWaiting(true);
|
||||||
setUpdateEmailError("");
|
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() {
|
async function handleUpgrade() {
|
||||||
setUpgradeError("");
|
setUpgradeError("");
|
||||||
|
@ -74,7 +76,7 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
|
||||||
upgradeAccount(upgradePassword).then((res) => {
|
upgradeAccount(upgradePassword).then((res) => {
|
||||||
setWaiting(false);
|
setWaiting(false);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
loadUserInfo();
|
loadUser();
|
||||||
closeUpgrade();
|
closeUpgrade();
|
||||||
} else {
|
} else {
|
||||||
setUpgradeError("Incorrect password");
|
setUpgradeError("Incorrect password");
|
||||||
|
@ -84,6 +86,9 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
|
||||||
setUpgradeError(err.message);
|
setUpgradeError(err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function handleOpenSignup() {
|
||||||
|
popupRef = window.open("https://account.lingdocs.com", "account", "height=800,width=500,top=50,left=400");
|
||||||
|
}
|
||||||
function handlePublish() {
|
function handlePublish() {
|
||||||
setPublishingStatus("publishing");
|
setPublishingStatus("publishing");
|
||||||
publishDictionary().then((response) => {
|
publishDictionary().then((response) => {
|
||||||
|
@ -93,56 +98,6 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
|
||||||
setPublishingStatus("Offline or connection error");
|
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) {
|
if (!user) {
|
||||||
return <div className="text-center mt-3">
|
return <div className="text-center mt-3">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
@ -150,33 +105,23 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
|
||||||
<meta name="description" content="Sign in to the LingDocs Pashto Dictionary" />
|
<meta name="description" content="Sign in to the LingDocs Pashto Dictionary" />
|
||||||
<title>Sign In - LingDocs Pashto Dictionary</title>
|
<title>Sign In - LingDocs Pashto Dictionary</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<h4 className="mb-4">Sign in to be able to suggest words/edits</h4>
|
<h2 className="my-4">Sign in to LingDocs</h2>
|
||||||
<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>
|
<p className="lead mb-4">When you sign in or make a LingDocs account you can:</p>
|
||||||
<StyledFirebaseAuth uiConfig={authUiConfig}
|
<div className="mb-3"><i className="fas fa-pen mr-2" /> contribute by suggesting corrections and new words</div>
|
||||||
// callbacks: {
|
<div className="mb-3"><i className="fas fa-star mr-2" /> upgrade your account and start collecting a personal <strong>wordlist</strong></div>
|
||||||
// not using this now because of the doubling down on user email verification
|
<div className="mb-3"><i className="fas fa-sync mr-2" /> sync your script preferences across devices</div>
|
||||||
// signInSuccessWithAuthResult: (res: any) => {
|
<button className="btn btn-lg btn-primary my-4" onClick={handleOpenSignup}><i className="fas fa-sign-in-alt mr-2" /> Sign In</button>
|
||||||
// const newUser = res.additionalUserInfo?.isNewUser;
|
</div>
|
||||||
// const emailVerified = res.user.emailVerified;
|
|
||||||
// if (newUser && !emailVerified) {
|
|
||||||
// res.user.sendEmailVerification();
|
|
||||||
// setEmailVerification("sent");
|
|
||||||
// }
|
|
||||||
// return false;
|
|
||||||
// }}
|
|
||||||
firebaseAuth={auth} />
|
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
const defaultProviderId = user.providerData[0]?.providerId;
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: "100px" }}>
|
<div style={{ marginBottom: "100px", maxWidth: "40rem" }}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<link rel="canonical" href="https://dictionary.lingdocs.com/account" />
|
<link rel="canonical" href="https://dictionary.lingdocs.com/account" />
|
||||||
<meta name="description" content="Account for the LingDocs Pashto Dictionary" />
|
<meta name="description" content="Account for the LingDocs Pashto Dictionary" />
|
||||||
<title>Account - LingDocs Pashto Dictionary</title>
|
<title>Account - LingDocs Pashto Dictionary</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<h2 className="mb-4">Account</h2>
|
<h2 className="mb-4">Account</h2>
|
||||||
{level === "editor" &&
|
{user.level === "editor" &&
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h4>Editor Tools</h4>
|
<h4>Editor Tools</h4>
|
||||||
{publishingStatus !== "publishing" &&
|
{publishingStatus !== "publishing" &&
|
||||||
|
@ -196,103 +141,65 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div style={{ maxWidth: "35rem" }}>
|
<div>
|
||||||
{user.photoURL && <div className="mb-4 mt-3" style={{ textAlign: "center" }}>
|
{/* {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" }}/>
|
<img src={user.photoURL} data-testid="userAvatar" alt="avatar" style={{ borderRadius: "50%", width: "5rem", height: "5rem" }}/>
|
||||||
</div>}
|
</div>} */}
|
||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<ul className="list-group list-group-flush">
|
<ul className="list-group list-group-flush">
|
||||||
<li className="list-group-item">Name: {user.displayName}</li>
|
<li className="list-group-item">Name: {user.name}</li>
|
||||||
<li className="list-group-item">
|
{user.email && <li className="list-group-item">
|
||||||
{user.email && <div className="d-flex justify-content-between align-items-center">
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<div>Email: {user.email}
|
<div>Email: {user.email}</div>
|
||||||
{emailVerification === "unverified" && <button type="button" onClick={handleVerifyEmail} className="ml-3 btn btn-sm btn-primary">
|
|
||||||
Verify Email
|
|
||||||
</button>}
|
|
||||||
</div>
|
</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>
|
</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>
|
||||||
<li className="list-group-item">Account Level: {capitalize(level)}</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{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="row mb-4">
|
||||||
|
{user.level === "basic" && <div className="col-sm mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary mr-3 mb-4"
|
className="btn btn-outline-secondary"
|
||||||
onClick={handleSignOut}
|
|
||||||
data-testid="signoutButton"
|
|
||||||
>
|
|
||||||
<i className="fa fa-sign-out-alt"></i> Sign Out
|
|
||||||
</button>
|
|
||||||
<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)}
|
onClick={() => setShowingUpgradePrompt(true)}
|
||||||
data-testid="upgradeButton"
|
data-testid="upgradeButton"
|
||||||
>
|
>
|
||||||
<i className="fa fa-level-up-alt"></i> Upgrade Account
|
<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>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary d-block my-3"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
data-testid="signoutButton"
|
|
||||||
>
|
|
||||||
<i className="fa fa-sign-out-alt"></i> Sign Out
|
|
||||||
</button>
|
</button>
|
||||||
</div>}
|
</div>}
|
||||||
<Modal.Footer>
|
<div className="col-sm mb-3">
|
||||||
<Button variant="secondary" onClick={() => setShowingDeleteConfirmation(false)}>
|
<a className="btn btn-outline-secondary" href="https://account.lingdocs.com/user">
|
||||||
No, cancel
|
<i className="fas fa-user mr-2"></i> Edit Account
|
||||||
</Button>
|
</a>
|
||||||
<Button variant="danger" onClick={handleDelete}>
|
</div>
|
||||||
Yes, delete my account
|
<div className="col-sm mb-3">
|
||||||
</Button>
|
<button className="btn btn-outline-secondary" onClick={handleSignOut}>
|
||||||
</Modal.Footer>
|
<i className="fas fa-sign-out-alt mr-2"></i> Sign Out
|
||||||
</Modal>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Modal show={showingUpgradePrompt} onHide={closeUpgrade}>
|
<Modal show={showingUpgradePrompt} onHide={closeUpgrade}>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>Upgrade Account</Modal.Title>
|
<Modal.Title>Upgrade Account</Modal.Title>
|
||||||
</Modal.Header>
|
</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">
|
<div className="form-group px-3">
|
||||||
<label htmlFor="upgradePasswordForm">Upgrade password:</label>
|
<label htmlFor="upgradePasswordForm">Upgrade password:</label>
|
||||||
<input
|
<input
|
||||||
|
@ -319,84 +226,6 @@ const Account = ({ handleSignOut, level, loadUserInfo }: {
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,11 +18,12 @@ import {
|
||||||
validateEntry,
|
validateEntry,
|
||||||
} from "@lingdocs/pashto-inflector";
|
} from "@lingdocs/pashto-inflector";
|
||||||
import Entry from "../components/Entry";
|
import Entry from "../components/Entry";
|
||||||
import * as BT from "../lib/backend-types";
|
import * as FT from "../lib/functions-types";
|
||||||
import {
|
import {
|
||||||
submissionBase,
|
submissionBase,
|
||||||
addSubmission,
|
addSubmission,
|
||||||
} from "../lib/submissions";
|
} from "../lib/submissions";
|
||||||
|
import { getTextOptions } from "../lib/get-text-options";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
const textFields: {field: T.DictionaryEntryTextField, label: string}[] = [
|
const textFields: {field: T.DictionaryEntryTextField, label: string}[] = [
|
||||||
|
@ -116,6 +117,7 @@ function EntryEditor({ state, dictionary, searchParams }: {
|
||||||
setMatchingEntries(state.isolatedEntry ? searchForMatchingEntries(state.isolatedEntry.p) : []);
|
setMatchingEntries(state.isolatedEntry ? searchForMatchingEntries(state.isolatedEntry.p) : []);
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
const textOptions = getTextOptions(state);
|
||||||
function searchForMatchingEntries(s: string): T.DictionaryEntry[] {
|
function searchForMatchingEntries(s: string): T.DictionaryEntry[] {
|
||||||
return dictionary.exactPashtoSearch(s)
|
return dictionary.exactPashtoSearch(s)
|
||||||
.filter((w) => w.ts !== state.isolatedEntry?.ts);
|
.filter((w) => w.ts !== state.isolatedEntry?.ts);
|
||||||
|
@ -136,18 +138,20 @@ function EntryEditor({ state, dictionary, searchParams }: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
const submission: BT.EntryDeletion = {
|
if (!state.user) return;
|
||||||
...submissionBase(),
|
const submission: FT.EntryDeletion = {
|
||||||
|
...submissionBase(state.user),
|
||||||
type: "entry deletion",
|
type: "entry deletion",
|
||||||
ts: entry.ts,
|
ts: entry.ts,
|
||||||
};
|
};
|
||||||
addSubmission(submission, state.options.level);
|
addSubmission(submission, state.user);
|
||||||
setDeleted(true);
|
setDeleted(true);
|
||||||
}
|
}
|
||||||
function handleSubmit(e: any) {
|
function handleSubmit(e: any) {
|
||||||
setErroneousFields([]);
|
setErroneousFields([]);
|
||||||
setErrors([]);
|
setErrors([]);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!state.user) return;
|
||||||
const result = validateEntry(entry);
|
const result = validateEntry(entry);
|
||||||
if ("errors" in result) {
|
if ("errors" in result) {
|
||||||
setErroneousFields(result.erroneousFields);
|
setErroneousFields(result.erroneousFields);
|
||||||
|
@ -155,12 +159,12 @@ function EntryEditor({ state, dictionary, searchParams }: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// TODO: Check complement if checkComplement
|
// TODO: Check complement if checkComplement
|
||||||
const submission: BT.NewEntry | BT.EntryEdit = {
|
const submission: FT.NewEntry | FT.EntryEdit = {
|
||||||
...submissionBase(),
|
...submissionBase(state.user),
|
||||||
type: entry.ts === 1 ? "new entry" : "entry edit",
|
type: entry.ts === 1 ? "new entry" : "entry edit",
|
||||||
entry: { ...entry, ts: entry.ts === 1 ? Date.now() : entry.ts },
|
entry: { ...entry, ts: entry.ts === 1 ? Date.now() : entry.ts },
|
||||||
};
|
};
|
||||||
addSubmission(submission, state.options.level);
|
addSubmission(submission, state.user);
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
// TODO: Remove from suggestions
|
// TODO: Remove from suggestions
|
||||||
// if (willDeleteSuggestion && sTs) {
|
// if (willDeleteSuggestion && sTs) {
|
||||||
|
@ -179,15 +183,15 @@ function EntryEditor({ state, dictionary, searchParams }: {
|
||||||
})();
|
})();
|
||||||
const linkField: { field: "l", label: string | JSX.Element } = {
|
const linkField: { field: "l", label: string | JSX.Element } = {
|
||||||
field: "l",
|
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" }}>
|
return <div className="width-limiter" style={{ marginBottom: "70px" }}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<link rel="canonical" href="https://dictionary.lingdocs.com/edit" />
|
<link rel="canonical" href="https://dictionary.lingdocs.com/edit" />
|
||||||
<title>Edit - LingDocs Pashto Dictionary</title>
|
<title>Edit - LingDocs Pashto Dictionary</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
{state.isolatedEntry && <Entry nonClickable entry={state.isolatedEntry} textOptions={state.options.textOptions} isolateEntry={() => null} />}
|
{state.isolatedEntry && <Entry nonClickable entry={state.isolatedEntry} textOptions={textOptions} isolateEntry={() => null} />}
|
||||||
{suggestedWord && <InlinePs opts={state.options.textOptions}>{suggestedWord}</InlinePs>}
|
{suggestedWord && <InlinePs opts={textOptions}>{suggestedWord}</InlinePs>}
|
||||||
{comment && <p>Comment: "{comment}"</p>}
|
{comment && <p>Comment: "{comment}"</p>}
|
||||||
{submitted ? "Edit submitted/saved" : deleted ? "Entry Deleted" :
|
{submitted ? "Edit submitted/saved" : deleted ? "Entry Deleted" :
|
||||||
<div>
|
<div>
|
||||||
|
@ -196,7 +200,7 @@ function EntryEditor({ state, dictionary, searchParams }: {
|
||||||
{matchingEntries.map((entry) => (
|
{matchingEntries.map((entry) => (
|
||||||
<div key={entry.ts}>
|
<div key={entry.ts}>
|
||||||
<Link to={`/edit?id=${entry.ts}`} className="plain-link">
|
<Link to={`/edit?id=${entry.ts}`} className="plain-link">
|
||||||
<InlinePs opts={state.options.textOptions}>{entry}</InlinePs>
|
<InlinePs opts={textOptions}>{entry}</InlinePs>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -328,12 +332,12 @@ function EntryEditor({ state, dictionary, searchParams }: {
|
||||||
</ul>
|
</ul>
|
||||||
</div>}
|
</div>}
|
||||||
</form>
|
</form>
|
||||||
{inflections && <InflectionsTable inf={inflections} textOptions={state.options.textOptions} />}
|
{inflections && <InflectionsTable inf={inflections} textOptions={textOptions} />}
|
||||||
{/* TODO: aay tail from state options */}
|
{/* TODO: aay tail from state options */}
|
||||||
<ConjugationViewer
|
<ConjugationViewer
|
||||||
entry={entry}
|
entry={entry}
|
||||||
complement={complement}
|
complement={complement}
|
||||||
textOptions={state.options.textOptions}
|
textOptions={textOptions}
|
||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { auth } from "../lib/firebase";
|
|
||||||
import {
|
import {
|
||||||
ConjugationViewer,
|
ConjugationViewer,
|
||||||
InflectionsTable,
|
InflectionsTable,
|
||||||
|
@ -28,12 +27,11 @@ import {
|
||||||
deleteWordFromWordlist,
|
deleteWordFromWordlist,
|
||||||
hasAttachment,
|
hasAttachment,
|
||||||
} from "../lib/wordlist-database";
|
} from "../lib/wordlist-database";
|
||||||
import {
|
import { wordlistEnabled } from "../lib/level-management";
|
||||||
wordlistEnabled,
|
|
||||||
} from "../lib/level-management";
|
|
||||||
import AudioPlayButton from "../components/AudioPlayButton";
|
import AudioPlayButton from "../components/AudioPlayButton";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { Modal } from "react-bootstrap";
|
import { Modal } from "react-bootstrap";
|
||||||
|
import { getTextOptions } from "../lib/get-text-options";
|
||||||
|
|
||||||
function IsolatedEntry({ state, dictionary, isolateEntry }: {
|
function IsolatedEntry({ state, dictionary, isolateEntry }: {
|
||||||
state: State,
|
state: State,
|
||||||
|
@ -50,14 +48,16 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
|
||||||
setEditSubmitted(false);
|
setEditSubmitted(false);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
const wordlistWord = state.wordlist.find((w) => w.entry.ts === state.isolatedEntry?.ts);
|
const wordlistWord = state.wordlist.find((w) => w.entry.ts === state.isolatedEntry?.ts);
|
||||||
|
const textOptions = getTextOptions(state);
|
||||||
function submitEdit() {
|
function submitEdit() {
|
||||||
if (!state.isolatedEntry) return;
|
if (!state.isolatedEntry) return;
|
||||||
|
if (!state.user) return;
|
||||||
addSubmission({
|
addSubmission({
|
||||||
...submissionBase(),
|
...submissionBase(state.user),
|
||||||
type: "edit suggestion",
|
type: "edit suggestion",
|
||||||
entry: state.isolatedEntry,
|
entry: state.isolatedEntry,
|
||||||
comment,
|
comment,
|
||||||
}, state.options.level);
|
}, state.user);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setComment("");
|
setComment("");
|
||||||
setEditSubmitted(true);
|
setEditSubmitted(true);
|
||||||
|
@ -104,14 +104,14 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
|
||||||
<Entry
|
<Entry
|
||||||
nonClickable
|
nonClickable
|
||||||
entry={entry}
|
entry={entry}
|
||||||
textOptions={state.options.textOptions}
|
textOptions={textOptions}
|
||||||
isolateEntry={isolateEntry}
|
isolateEntry={isolateEntry}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{auth.currentUser &&
|
{state.user &&
|
||||||
<div className="col-4">
|
<div className="col-4">
|
||||||
<div className="d-flex flex-row justify-content-end">
|
<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">
|
<Link to={`/edit?id=${entry.ts}`} className="plain-link">
|
||||||
<div
|
<div
|
||||||
className="clickable mr-3"
|
className="clickable mr-3"
|
||||||
|
@ -128,7 +128,7 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
|
||||||
>
|
>
|
||||||
<i className="fa fa-pen"></i>
|
<i className="fa fa-pen"></i>
|
||||||
</div>
|
</div>
|
||||||
{wordlistEnabled(state) && <div
|
{wordlistEnabled(state.user) && <div
|
||||||
className="clickable"
|
className="clickable"
|
||||||
data-testid={wordlistWord ? "fullStarButton" : "emptyStarButton"}
|
data-testid={wordlistWord ? "fullStarButton" : "emptyStarButton"}
|
||||||
onClick={wordlistWord
|
onClick={wordlistWord
|
||||||
|
@ -180,12 +180,12 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{editSubmitted && <p>Thank you for your help!</p>}
|
{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 */}
|
{/* TODO: State options for tail type here */}
|
||||||
<ConjugationViewer
|
<ConjugationViewer
|
||||||
entry={entry}
|
entry={entry}
|
||||||
complement={complement}
|
complement={complement}
|
||||||
textOptions={state.options.textOptions}
|
textOptions={textOptions}
|
||||||
/>
|
/>
|
||||||
{relatedEntries && <>
|
{relatedEntries && <>
|
||||||
{relatedEntries.length ?
|
{relatedEntries.length ?
|
||||||
|
@ -209,7 +209,7 @@ function IsolatedEntry({ state, dictionary, isolateEntry }: {
|
||||||
<Modal.Title>Delete from wordlist?</Modal.Title>
|
<Modal.Title>Delete from wordlist?</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>Delete <InlinePs
|
<Modal.Body>Delete <InlinePs
|
||||||
opts={state.options.textOptions}
|
opts={textOptions}
|
||||||
>{{ p: entry.p, f: entry.f }}</InlinePs> from your wordlist?
|
>{{ p: entry.p, f: entry.f }}</InlinePs> from your wordlist?
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
|
|
|
@ -129,10 +129,14 @@ const booleanOptions: {
|
||||||
|
|
||||||
function Options({
|
function Options({
|
||||||
options,
|
options,
|
||||||
|
state,
|
||||||
optionsDispatch,
|
optionsDispatch,
|
||||||
|
textOptionsDispatch,
|
||||||
}: {
|
}: {
|
||||||
options: Options,
|
options: Options,
|
||||||
|
state: State,
|
||||||
optionsDispatch: (action: OptionsAction) => void,
|
optionsDispatch: (action: OptionsAction) => void,
|
||||||
|
textOptionsDispatch: (action: TextOptionsAction) => void,
|
||||||
}) {
|
}) {
|
||||||
return <div style={{ maxWidth: "700px", marginBottom: "150px" }}>
|
return <div style={{ maxWidth: "700px", marginBottom: "150px" }}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
@ -152,7 +156,7 @@ function Options({
|
||||||
<td><kbd>ctrl / ⌘</kbd> + <kbd>b</kbd></td>
|
<td><kbd>ctrl / ⌘</kbd> + <kbd>b</kbd></td>
|
||||||
<td>clear search</td>
|
<td>clear search</td>
|
||||||
</tr>
|
</tr>
|
||||||
{wordlistEnabled(options.level) && <tr>
|
{wordlistEnabled(state.user) && <tr>
|
||||||
<td><kbd>ctrl / ⌘</kbd> + <kbd>\</kbd></td>
|
<td><kbd>ctrl / ⌘</kbd> + <kbd>\</kbd></td>
|
||||||
<td>show/hide wordlist</td>
|
<td>show/hide wordlist</td>
|
||||||
</tr>}
|
</tr>}
|
||||||
|
@ -173,7 +177,7 @@ function Options({
|
||||||
handleChange={(p) => optionsDispatch({ type: "changeSearchBarPosition", payload: p as SearchBarPosition })}
|
handleChange={(p) => optionsDispatch({ type: "changeSearchBarPosition", payload: p as SearchBarPosition })}
|
||||||
/>
|
/>
|
||||||
<div className="small mt-2">Bottom position doesn't work well with iPhones.</div>
|
<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>
|
<h4 className="mt-3">Show Number of Wordlist Words for Review</h4>
|
||||||
<ButtonSelect
|
<ButtonSelect
|
||||||
small
|
small
|
||||||
|
@ -186,22 +190,22 @@ function Options({
|
||||||
<ButtonSelect
|
<ButtonSelect
|
||||||
small
|
small
|
||||||
options={fontSizeOptions}
|
options={fontSizeOptions}
|
||||||
value={options.textOptions.pTextSize}
|
value={options.textOptionsRecord.textOptions.pTextSize}
|
||||||
handleChange={(p) => optionsDispatch({ type: "changePTextSize", payload: p as PTextSize })}
|
handleChange={(p) => textOptionsDispatch({ type: "changePTextSize", payload: p as PTextSize })}
|
||||||
/>
|
/>
|
||||||
<h4 className="mt-3">Diacritics</h4>
|
<h4 className="mt-3">Diacritics</h4>
|
||||||
<ButtonSelect
|
<ButtonSelect
|
||||||
small
|
small
|
||||||
options={booleanOptions}
|
options={booleanOptions}
|
||||||
value={options.textOptions.diacritics.toString()}
|
value={options.textOptionsRecord.textOptions.diacritics.toString()}
|
||||||
handleChange={(p) => optionsDispatch({ type: "changeDiacritics", payload: p === "true" })}
|
handleChange={(p) => textOptionsDispatch({ type: "changeDiacritics", payload: p === "true" })}
|
||||||
/>
|
/>
|
||||||
<h4 className="mt-3">Pashto Spelling</h4>
|
<h4 className="mt-3">Pashto Spelling</h4>
|
||||||
<ButtonSelect
|
<ButtonSelect
|
||||||
small
|
small
|
||||||
options={spellingOptions}
|
options={spellingOptions}
|
||||||
value={options.textOptions.spelling}
|
value={options.textOptionsRecord.textOptions.spelling}
|
||||||
handleChange={(p) => optionsDispatch({ type: "changeSpelling", payload: p as T.Spelling })}
|
handleChange={(p) => textOptionsDispatch({ type: "changeSpelling", payload: p as T.Spelling })}
|
||||||
/>
|
/>
|
||||||
{/* NEED TO UPDATE THE PHONETICS DIALECT OPTION THING */}
|
{/* NEED TO UPDATE THE PHONETICS DIALECT OPTION THING */}
|
||||||
{/* <h4 className="mt-3">Phonetics</h4>
|
{/* <h4 className="mt-3">Phonetics</h4>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import * as BT from "../lib/backend-types";
|
import * as FT from "../lib/functions-types";
|
||||||
import {
|
import {
|
||||||
submissionBase,
|
submissionBase,
|
||||||
addSubmission,
|
addSubmission,
|
||||||
|
@ -15,7 +15,6 @@ import {
|
||||||
import { isPashtoScript } from "../lib/is-pashto";
|
import { isPashtoScript } from "../lib/is-pashto";
|
||||||
import Entry from "../components/Entry";
|
import Entry from "../components/Entry";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { auth } from "../lib/firebase";
|
|
||||||
import { allEntries } from "../lib/dictionary";
|
import { allEntries } from "../lib/dictionary";
|
||||||
import {
|
import {
|
||||||
standardizePashto,
|
standardizePashto,
|
||||||
|
@ -24,6 +23,7 @@ import {
|
||||||
} from "@lingdocs/pashto-inflector";
|
} from "@lingdocs/pashto-inflector";
|
||||||
import InflectionSearchResult from "../components/InflectionSearchResult";
|
import InflectionSearchResult from "../components/InflectionSearchResult";
|
||||||
import { searchAllInflections } from "../lib/search-all-inflections";
|
import { searchAllInflections } from "../lib/search-all-inflections";
|
||||||
|
import { getTextOptions } from "../lib/get-text-options";
|
||||||
|
|
||||||
const inflectionSearchIcon = "fas fa-search-plus";
|
const inflectionSearchIcon = "fas fa-search-plus";
|
||||||
|
|
||||||
|
@ -42,6 +42,7 @@ function Results({ state, isolateEntry }: {
|
||||||
const [pashto, setPashto] = useState<string>("");
|
const [pashto, setPashto] = useState<string>("");
|
||||||
const [phonetics, setPhonetics] = useState<string>("");
|
const [phonetics, setPhonetics] = useState<string>("");
|
||||||
const [english, setEnglish] = useState<string>("");
|
const [english, setEnglish] = useState<string>("");
|
||||||
|
const textOptions = getTextOptions(state);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPowerResults(undefined);
|
setPowerResults(undefined);
|
||||||
}, [state.searchValue])
|
}, [state.searchValue])
|
||||||
|
@ -63,16 +64,17 @@ function Results({ state, isolateEntry }: {
|
||||||
}
|
}
|
||||||
function submitSuggestion(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
function submitSuggestion(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!state.user) return;
|
||||||
const p = pashto;
|
const p = pashto;
|
||||||
const f = phonetics;
|
const f = phonetics;
|
||||||
const e = english;
|
const e = english;
|
||||||
const newEntry: BT.EntrySuggestion = {
|
const newEntry: FT.EntrySuggestion = {
|
||||||
...submissionBase(),
|
...submissionBase(state.user),
|
||||||
type: "entry suggestion",
|
type: "entry suggestion",
|
||||||
entry: { ts: 0, i: 0, p, f, g: "", e },
|
entry: { ts: 0, i: 0, p, f, g: "", e },
|
||||||
comment,
|
comment,
|
||||||
};
|
};
|
||||||
addSubmission(newEntry, state.options.level);
|
addSubmission(newEntry, state.user);
|
||||||
setSuggestionState("received");
|
setSuggestionState("received");
|
||||||
}
|
}
|
||||||
function handlePowerSearch() {
|
function handlePowerSearch() {
|
||||||
|
@ -82,7 +84,7 @@ function Results({ state, isolateEntry }: {
|
||||||
const allDocs = allEntries();
|
const allDocs = allEntries();
|
||||||
const results = searchAllInflections(
|
const results = searchAllInflections(
|
||||||
allDocs,
|
allDocs,
|
||||||
prepValueForSearch(state.searchValue, state.options.textOptions),
|
prepValueForSearch(state.searchValue, textOptions),
|
||||||
);
|
);
|
||||||
setPowerResults(results);
|
setPowerResults(results);
|
||||||
}, 20);
|
}, 20);
|
||||||
|
@ -91,7 +93,7 @@ function Results({ state, isolateEntry }: {
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>LingDocs Pashto Dictionary</title>
|
<title>LingDocs Pashto Dictionary</title>
|
||||||
</Helmet>
|
</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"
|
type="button"
|
||||||
className={`btn btn-outline-secondary bg-white entry-suggestion-button${state.options.searchBarPosition === "bottom" ? " entry-suggestion-button-with-bottom-searchbar" : ""}`}
|
className={`btn btn-outline-secondary bg-white entry-suggestion-button${state.options.searchBarPosition === "bottom" ? " entry-suggestion-button-with-bottom-searchbar" : ""}`}
|
||||||
onClick={startSuggestion}
|
onClick={startSuggestion}
|
||||||
|
@ -118,14 +120,14 @@ function Results({ state, isolateEntry }: {
|
||||||
<Entry
|
<Entry
|
||||||
key={p.entry.i}
|
key={p.entry.i}
|
||||||
entry={p.entry}
|
entry={p.entry}
|
||||||
textOptions={state.options.textOptions}
|
textOptions={textOptions}
|
||||||
isolateEntry={isolateEntry}
|
isolateEntry={isolateEntry}
|
||||||
/>
|
/>
|
||||||
<div className="mb-3 ml-2">
|
<div className="mb-3 ml-2">
|
||||||
{p.results.map((result: InflectionSearchResult, i) => (
|
{p.results.map((result: InflectionSearchResult, i) => (
|
||||||
<InflectionSearchResult
|
<InflectionSearchResult
|
||||||
key={"inf-result" + i}
|
key={"inf-result" + i}
|
||||||
textOptions={state.options.textOptions}
|
textOptions={textOptions}
|
||||||
result={result}
|
result={result}
|
||||||
entry={p.entry}
|
entry={p.entry}
|
||||||
/>
|
/>
|
||||||
|
@ -138,11 +140,11 @@ function Results({ state, isolateEntry }: {
|
||||||
<Entry
|
<Entry
|
||||||
key={entry.i}
|
key={entry.i}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
textOptions={state.options.textOptions}
|
textOptions={textOptions}
|
||||||
isolateEntry={isolateEntry}
|
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>
|
<h5 className="mb-3">Suggest an entry for the dictionary:</h5>
|
||||||
<div className="form-group mt-4" style={{ maxWidth: "500px" }}>
|
<div className="form-group mt-4" style={{ maxWidth: "500px" }}>
|
||||||
<div className="row mb-2">
|
<div className="row mb-2">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Entry from "../components/Entry";
|
import Entry from "../components/Entry";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import * as BT from "../lib/backend-types";
|
import * as FT from "../lib/functions-types";
|
||||||
import {
|
import {
|
||||||
deleteFromLocalDb,
|
deleteFromLocalDb,
|
||||||
} from "../lib/pouch-dbs";
|
} from "../lib/pouch-dbs";
|
||||||
|
@ -8,8 +8,9 @@ import {
|
||||||
Types as T,
|
Types as T,
|
||||||
} from "@lingdocs/pashto-inflector";
|
} from "@lingdocs/pashto-inflector";
|
||||||
import { Helmet } from "react-helmet";
|
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() {
|
function handleDelete() {
|
||||||
deleteFromLocalDb("reviewTasks", reviewTask._id);
|
deleteFromLocalDb("reviewTasks", reviewTask._id);
|
||||||
}
|
}
|
||||||
|
@ -40,7 +41,7 @@ function ReviewTask({ reviewTask, textOptions }: { reviewTask: BT.ReviewTask, te
|
||||||
</div>}
|
</div>}
|
||||||
<Entry textOptions={textOptions} entry={reviewTask.entry} />
|
<Entry textOptions={textOptions} entry={reviewTask.entry} />
|
||||||
<div className="mb-2">"{reviewTask.comment}"</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -50,13 +51,14 @@ function ReviewTask({ reviewTask, textOptions }: { reviewTask: BT.ReviewTask, te
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReviewTasks({ state }: { state: State }) {
|
export default function ReviewTasks({ state }: { state: State }) {
|
||||||
|
const textOptions = getTextOptions(state);
|
||||||
return <div className="width-limiter" style={{ marginBottom: "70px" }}>
|
return <div className="width-limiter" style={{ marginBottom: "70px" }}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>Review Tasks - LingDocs Pashto Dictionary</title>
|
<title>Review Tasks - LingDocs Pashto Dictionary</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<h3 className="mb-4">Review Tasks</h3>
|
<h3 className="mb-4">Review Tasks</h3>
|
||||||
{state.reviewTasks.length ?
|
{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>
|
: <p>None</p>
|
||||||
}
|
}
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
@ -44,6 +44,8 @@ import AudioPlayButton from "../components/AudioPlayButton";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime.js";
|
import relativeTime from "dayjs/plugin/relativeTime.js";
|
||||||
import hitBottom from "../lib/hitBottom";
|
import hitBottom from "../lib/hitBottom";
|
||||||
|
import { getTextOptions } from "../lib/get-text-options";
|
||||||
|
|
||||||
const cleanupIcon = "broom";
|
const cleanupIcon = "broom";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
@ -94,6 +96,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, []);
|
}, []);
|
||||||
const toReview = forReview(state.wordlist);
|
const toReview = forReview(state.wordlist);
|
||||||
|
const textOptions = getTextOptions(state);
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
// TODO: DON'T HAVE ENDLESS PAGE INCREASING
|
// TODO: DON'T HAVE ENDLESS PAGE INCREASING
|
||||||
if (hitBottom() && state.options.wordlistMode === "browse") {
|
if (hitBottom() && state.options.wordlistMode === "browse") {
|
||||||
|
@ -116,7 +119,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
|
||||||
}
|
}
|
||||||
function handleSearchValueChange(value: string) {
|
function handleSearchValueChange(value: string) {
|
||||||
setWordlistSearchValue(value);
|
setWordlistSearchValue(value);
|
||||||
const results = value ? searchWordlist(value, state.wordlist, state.options.textOptions) : [];
|
const results = value ? searchWordlist(value, state.wordlist, textOptions) : [];
|
||||||
setFilteredWords(results);
|
setFilteredWords(results);
|
||||||
}
|
}
|
||||||
async function handleGetWordlistCSV() {
|
async function handleGetWordlistCSV() {
|
||||||
|
@ -152,7 +155,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
|
||||||
return <div className="mb-4">
|
return <div className="mb-4">
|
||||||
<Entry
|
<Entry
|
||||||
entry={word.entry}
|
entry={word.entry}
|
||||||
textOptions={state.options.textOptions}
|
textOptions={textOptions}
|
||||||
isolateEntry={() => handleWordClickBrowse(word._id)}
|
isolateEntry={() => handleWordClickBrowse(word._id)}
|
||||||
/>
|
/>
|
||||||
{hasAttachment(word, "audio") && <AudioPlayButton word={word} />}
|
{hasAttachment(word, "audio") && <AudioPlayButton word={word} />}
|
||||||
|
@ -217,14 +220,14 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h6 className="card-title text-center">
|
<h6 className="card-title text-center">
|
||||||
{state.options.wordlistReviewLanguage === "Pashto"
|
{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
|
: word.entry.e
|
||||||
}
|
}
|
||||||
</h6>
|
</h6>
|
||||||
{beingQuizzed && <div className="card-text text-center">
|
{beingQuizzed && <div className="card-text text-center">
|
||||||
{state.options.wordlistReviewLanguage === "Pashto"
|
{state.options.wordlistReviewLanguage === "Pashto"
|
||||||
? <div>{word.entry.e}</div>
|
? <div>{word.entry.e}</div>
|
||||||
: <InlinePs opts={state.options.textOptions}>
|
: <InlinePs opts={textOptions}>
|
||||||
{{ p: word.entry.p, f: word.entry.f }}
|
{{ p: word.entry.p, f: word.entry.f }}
|
||||||
</InlinePs>
|
</InlinePs>
|
||||||
}
|
}
|
||||||
|
@ -311,7 +314,7 @@ function Wordlist({ state, isolateEntry, optionsDispatch }: {
|
||||||
const { e, ...ps } = nextUp.entry;
|
const { e, ...ps } = nextUp.entry;
|
||||||
return <div>
|
return <div>
|
||||||
<div className="lead my-3">None to review</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)}
|
{removeFVariants(ps)}
|
||||||
</InlinePs></p>
|
</InlinePs></p>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
@ -17,12 +17,16 @@ type SearchBarPosition = "top" | "bottom";
|
||||||
|
|
||||||
type WordlistMode = "browse" | "review";
|
type WordlistMode = "browse" | "review";
|
||||||
|
|
||||||
|
type TextOptionsRecord = {
|
||||||
|
lastModified: import("./lib/account-types").TimeStamp,
|
||||||
|
textOptions: import("@lingdocs/pashto-inflector").Types.TextOptions,
|
||||||
|
};
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
language: Language,
|
language: Language,
|
||||||
searchType: SearchType,
|
searchType: SearchType,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
textOptions: import("@lingdocs/pashto-inflector").Types.TextOptions,
|
textOptionsRecord: TextOptionsRecord,
|
||||||
level: UserLevel,
|
|
||||||
wordlistMode: WordlistMode,
|
wordlistMode: WordlistMode,
|
||||||
wordlistReviewLanguage: Language,
|
wordlistReviewLanguage: Language,
|
||||||
wordlistReviewBadge: boolean,
|
wordlistReviewBadge: boolean,
|
||||||
|
@ -39,35 +43,21 @@ type State = {
|
||||||
isolatedEntry: import("@lingdocs/pashto-inflector").Types.DictionaryEntry | undefined,
|
isolatedEntry: import("@lingdocs/pashto-inflector").Types.DictionaryEntry | undefined,
|
||||||
results: import("@lingdocs/pashto-inflector").Types.DictionaryEntry[],
|
results: import("@lingdocs/pashto-inflector").Types.DictionaryEntry[],
|
||||||
wordlist: WordlistWord[],
|
wordlist: WordlistWord[],
|
||||||
reviewTasks: import("./lib/backend-types").ReviewTask[],
|
reviewTasks: import("./lib/functions-types").ReviewTask[],
|
||||||
dictionaryInfo: import("@lingdocs/pashto-inflector").Types.DictionaryInfo | undefined,
|
dictionaryInfo: import("@lingdocs/pashto-inflector").Types.DictionaryInfo | undefined,
|
||||||
|
user: undefined | import("./lib/account-types").LingdocsUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptionsAction = {
|
type OptionsAction = {
|
||||||
type: "toggleSearchType",
|
type: "toggleSearchType",
|
||||||
} | {
|
} | {
|
||||||
type: "toggleLanguage",
|
type: "toggleLanguage",
|
||||||
} | {
|
|
||||||
type: "changePTextSize",
|
|
||||||
payload: PTextSize,
|
|
||||||
} | {
|
} | {
|
||||||
type: "changeTheme",
|
type: "changeTheme",
|
||||||
payload: Theme,
|
payload: Theme,
|
||||||
} | {
|
} | {
|
||||||
type: "changeSearchBarPosition",
|
type: "changeSearchBarPosition",
|
||||||
payload: SearchBarPosition,
|
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",
|
type: "changeUserLevel",
|
||||||
payload: UserLevel,
|
payload: UserLevel,
|
||||||
|
@ -80,6 +70,26 @@ type OptionsAction = {
|
||||||
} | {
|
} | {
|
||||||
type: "changeWordlistReviewBadge",
|
type: "changeWordlistReviewBadge",
|
||||||
payload: boolean,
|
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 = {
|
type DictionaryAPI = {
|
||||||
|
|
4442
website/yarn.lock
4442
website/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue