Path: blob/master/src/packages/frontend/collaborators/project-invite-tokens.tsx
1496 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Manage tokens that can be used to add new users who7know the token to a project.89TODO:10- we don't allow adjusting the usage_limit, so hide that for now.11- the default expire time is "2 weeks" and user can't edit that yet, except to set expire to now.1213*/1415// Load the code that checks for the PROJECT_INVITE_QUERY_PARAM16// when user gets signed in, and handles it.1718import { Button, Card, DatePicker, Form, Modal, Popconfirm, Table } from "antd";19import dayjs from "dayjs";20import { join } from "path";2122import { alert_message } from "@cocalc/frontend/alerts";23import {24React,25useIsMountedRef,26useState,27} from "@cocalc/frontend/app-framework";28import {29CopyToClipBoard,30Gap,31Icon,32Loading,33TimeAgo,34} from "@cocalc/frontend/components";35import { appBasePath } from "@cocalc/frontend/customize/app-base-path";36import { CancelText } from "@cocalc/frontend/i18n/components";37import { webapp_client } from "@cocalc/frontend/webapp-client";38import { ProjectInviteToken } from "@cocalc/util/db-schema/project-invite-tokens";39import { secure_random_token, server_weeks_ago } from "@cocalc/util/misc";40import { PROJECT_INVITE_QUERY_PARAM } from "./handle-project-invite";4142const { useForm } = Form;4344const TOKEN_LENGTH = 16;45const MAX_TOKENS = 200;46const COLUMNS = [47{ title: "Invite Link", dataIndex: "token", key: "token", width: 300 },48{ title: "Created", dataIndex: "created", key: "created", width: 150 },49{ title: "Expires", dataIndex: "expires", key: "expires", width: 150 },50{ title: "Redemption Count", dataIndex: "counter", key: "counter" },51/* { title: "Limit", dataIndex: "usage_limit", key: "usage_limit" },*/52];5354interface Props {55project_id: string;56}5758export const ProjectInviteTokens: React.FC<Props> = React.memo(59({ project_id }) => {60// blah61const [expanded, set_expanded] = useState<boolean>(false);62const [tokens, set_tokens] = useState<undefined | ProjectInviteToken[]>(63undefined,64);65const is_mounted_ref = useIsMountedRef();66const [fetching, set_fetching] = useState<boolean>(false);67const [addModalVisible, setAddModalVisible] = useState<boolean>(false);68const [form] = useForm();6970async function fetch_tokens() {71try {72set_fetching(true);73const { query } = await webapp_client.async_query({74query: {75project_invite_tokens: [76{77project_id,78token: null,79created: null,80expires: null,81usage_limit: null,82counter: null,83},84],85},86});87if (!is_mounted_ref.current) return;88set_tokens(query.project_invite_tokens);89} catch (err) {90alert_message({91type: "error",92message: `Error getting project invite tokens: ${err}`,93});94} finally {95if (is_mounted_ref.current) {96set_fetching(false);97}98}99}100101const heading = (102<div>103<a104onClick={() => {105if (!expanded) {106fetch_tokens();107}108set_expanded(!expanded);109}}110style={{ cursor: "pointer", fontSize: "12pt" }}111>112{" "}113<Icon114style={{ width: "20px" }}115name={expanded ? "caret-down" : "caret-right"}116/>{" "}117Invite collaborators by sending them an invite URL...118</a>119</div>120);121if (!expanded) {122return heading;123}124125async function add_token(expires: Date) {126if (tokens != null && tokens.length > MAX_TOKENS) {127// TODO: just in case of some weird abuse... and until we implement128// deletion of tokens. Maybe the backend will just purge129// anything that has expired after a while.130alert_message({131type: "error",132message:133"You have hit the hard limit on the number of invite tokens for a single project. Please contact support.",134});135return;136}137const token = secure_random_token(TOKEN_LENGTH);138try {139await webapp_client.async_query({140query: {141project_invite_tokens: {142token,143project_id,144created: webapp_client.server_time(),145expires,146},147},148});149} catch (err) {150alert_message({151type: "error",152message: `Error creating project invite token: ${err}`,153});154}155if (!is_mounted_ref.current) return;156fetch_tokens();157}158159async function add_token_two_week() {160let expires = server_weeks_ago(-2);161add_token(expires);162}163164function render_create_token() {165return (166<Popconfirm167title={168"Create a link that people can use to get added as a collaborator to this project."169}170onConfirm={add_token_two_week}171okText={"Yes, create token"}172cancelText={<CancelText />}173>174<Button disabled={fetching}>175<Icon name="plus-circle" />176<Gap /> Create two weeks token177</Button>178</Popconfirm>179);180}181const handleAdd = () => {182setAddModalVisible(true);183};184185const handleModalOK = () => {186// const name = form.getFieldValue("name");187const expire = form.getFieldValue("expire");188add_token(expire.toDate());189setAddModalVisible(false);190form.resetFields();191};192193const handleModalCancel = () => {194setAddModalVisible(false);195form.resetFields();196};197198function render_create_custom_token() {199return (200<Button onClick={handleAdd}>201<Icon name="plus-circle" /> Create custom token202</Button>203);204}205206function render_refresh() {207return (208<Button onClick={fetch_tokens} disabled={fetching}>209<Icon name="refresh" spin={fetching} />210<Gap /> Refresh211</Button>212);213}214215async function expire_token(token) {216// set token to be expired217try {218await webapp_client.async_query({219query: {220project_invite_tokens: {221token,222project_id,223expires: webapp_client.server_time(),224},225},226});227} catch (err) {228alert_message({229type: "error",230message: `Error expiring project invite token: ${err}`,231});232}233if (!is_mounted_ref.current) return;234fetch_tokens();235}236237function render_expire_button(token, expires) {238if (expires && expires <= webapp_client.server_time()) {239return "(REVOKED)";240}241return (242<Popconfirm243title={"Revoke this token?"}244description={245<div style={{ maxWidth: "400px" }}>246This will make it so this token cannot be used anymore. Anybody247who has already redeemed the token is not removed from this248project.249</div>250}251onConfirm={() => expire_token(token)}252okText={"Yes, revoke this token"}253cancelText={"Cancel"}254>255<Button size="small">Revoke...</Button>256</Popconfirm>257);258}259260function render_tokens() {261if (tokens == null) return <Loading />;262const dataSource: any[] = [];263for (const data of tokens) {264const { token, counter, usage_limit, created, expires } = data;265dataSource.push({266key: token,267token:268expires && expires <= webapp_client.server_time() ? (269<span style={{ textDecoration: "line-through" }}>{token}</span>270) : (271<CopyToClipBoard272inputWidth="250px"273value={`${document.location.origin}${join(274appBasePath,275"app",276)}?${PROJECT_INVITE_QUERY_PARAM}=${token}`}277/>278),279counter,280usage_limit: usage_limit ?? "∞",281created: created ? <TimeAgo date={created} /> : undefined,282expires: expires ? (283<span>284<TimeAgo date={expires} /> <Gap />285{render_expire_button(token, expires)}286</span>287) : undefined,288data,289});290}291return (292<Table293dataSource={dataSource}294columns={COLUMNS}295pagination={{ pageSize: 4 }}296scroll={{ y: 240 }}297/>298);299}300301return (302<Card style={{ minWidth: "800px", width: "100%", overflow: "auto" }}>303{heading}304<br />305<br />306{render_create_token()}307<Gap />308{render_create_custom_token()}309<Gap />310{render_refresh()}311<br />312<br />313{render_tokens()}314<br />315<br />316<Modal317open={addModalVisible}318title="Create a New Inviting Token"319okText="Create token"320cancelText={<CancelText />}321onCancel={handleModalCancel}322onOk={handleModalOK}323>324<Form form={form} layout="vertical">325<Form.Item326name="expire"327label="Expire"328rules={[329{330required: false,331message:332"Optional date when token will be automatically expired",333},334]}335>336<DatePicker337changeOnBlur338showTime339disabledDate={(current) => {340// disable all dates before today341return current && current < dayjs();342}}343/>344</Form.Item>345</Form>346</Modal>347</Card>348);349},350);351352353