commit
feafda8fda
@ -0,0 +1,197 @@
|
||||
import {buildDropdownConditionEditor} from 'app/client/components/DropdownConditionEditor';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ViewFieldRec} from 'app/client/models/DocModel';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
||||
import {textButton } from 'app/client/ui2018/buttons';
|
||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
||||
import {getPredicateFormulaProperties} from 'app/common/PredicateFormula';
|
||||
import {Computed, Disposable, dom, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('DropdownConditionConfig');
|
||||
|
||||
/**
|
||||
* Right panel configuration for dropdown conditions.
|
||||
*
|
||||
* Contains an instance of `DropdownConditionEditor`, the class responsible
|
||||
* for setting dropdown conditions.
|
||||
*/
|
||||
export class DropdownConditionConfig extends Disposable {
|
||||
private _text = Computed.create(this, use => {
|
||||
const dropdownCondition = use(this._field.dropdownCondition);
|
||||
if (!dropdownCondition) { return ''; }
|
||||
|
||||
return dropdownCondition.text;
|
||||
});
|
||||
|
||||
private _saveError = Observable.create<string | null>(this, null);
|
||||
|
||||
private _properties = Computed.create(this, use => {
|
||||
const dropdownCondition = use(this._field.dropdownCondition);
|
||||
if (!dropdownCondition?.parsed) { return null; }
|
||||
|
||||
return getPredicateFormulaProperties(JSON.parse(dropdownCondition.parsed));
|
||||
});
|
||||
|
||||
private _column = Computed.create(this, use => use(this._field.column));
|
||||
|
||||
private _columns = Computed.create(this, use => use(use(use(this._column).table).visibleColumns));
|
||||
|
||||
private _refColumns = Computed.create(this, use => {
|
||||
const refTable = use(use(this._column).refTable);
|
||||
if (!refTable) { return null; }
|
||||
|
||||
return use(refTable.visibleColumns);
|
||||
});
|
||||
|
||||
private _propertiesError = Computed.create<string | null>(this, use => {
|
||||
const properties = use(this._properties);
|
||||
if (!properties) { return null; }
|
||||
|
||||
const {recColIds = [], choiceColIds = []} = properties;
|
||||
const columns = use(this._columns);
|
||||
const validRecColIds = new Set(columns.map((({colId}) => use(colId))));
|
||||
const invalidRecColIds = recColIds.filter(colId => !validRecColIds.has(colId));
|
||||
if (invalidRecColIds.length > 0) {
|
||||
return t('Invalid columns: {{colIds}}', {colIds: invalidRecColIds.join(', ')});
|
||||
}
|
||||
|
||||
const refColumns = use(this._refColumns);
|
||||
if (refColumns) {
|
||||
const validChoiceColIds = new Set(['id', ...refColumns.map((({colId}) => use(colId)))]);
|
||||
const invalidChoiceColIds = choiceColIds.filter(colId => !validChoiceColIds.has(colId));
|
||||
if (invalidChoiceColIds.length > 0) {
|
||||
return t('Invalid columns: {{colIds}}', {colIds: invalidChoiceColIds.join(', ')});
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
private _error = Computed.create<string | null>(this, (use) => {
|
||||
const maybeSaveError = use(this._saveError);
|
||||
if (maybeSaveError) { return maybeSaveError; }
|
||||
|
||||
const maybeCompiled = use(this._field.dropdownConditionCompiled);
|
||||
if (maybeCompiled?.kind === 'failure') { return maybeCompiled.error; }
|
||||
|
||||
const maybePropertiesError = use(this._propertiesError);
|
||||
if (maybePropertiesError) { return maybePropertiesError; }
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
private _disabled = Computed.create(this, use =>
|
||||
use(this._field.disableModify) ||
|
||||
use(use(this._column).disableEditData) ||
|
||||
use(this._field.config.multiselect)
|
||||
);
|
||||
|
||||
private _isEditingCondition = Observable.create(this, false);
|
||||
|
||||
private _isRefField = Computed.create(this, (use) =>
|
||||
['Ref', 'RefList'].includes(use(use(this._column).pureType)));
|
||||
|
||||
private _tooltip = Computed.create(this, use => use(this._isRefField)
|
||||
? 'setRefDropdownCondition'
|
||||
: 'setChoiceDropdownCondition');
|
||||
|
||||
private _editorElement: HTMLElement;
|
||||
|
||||
constructor(private _field: ViewFieldRec) {
|
||||
super();
|
||||
|
||||
this.autoDispose(this._text.addListener(() => {
|
||||
this._saveError.set('');
|
||||
}));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return [
|
||||
dom.maybe((use) => !(use(this._isEditingCondition) || Boolean(use(this._text))), () => [
|
||||
cssSetDropdownConditionRow(
|
||||
dom.domComputed(use => withInfoTooltip(
|
||||
textButton(
|
||||
t('Set dropdown condition'),
|
||||
dom.on('click', () => {
|
||||
this._isEditingCondition.set(true);
|
||||
setTimeout(() => this._editorElement.focus(), 0);
|
||||
}),
|
||||
dom.prop('disabled', this._disabled),
|
||||
testId('field-set-dropdown-condition'),
|
||||
),
|
||||
use(this._tooltip),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
dom.maybe((use) => use(this._isEditingCondition) || Boolean(use(this._text)), () => [
|
||||
cssLabel(t('Dropdown Condition')),
|
||||
cssRow(
|
||||
dom.create(buildDropdownConditionEditor,
|
||||
{
|
||||
value: this._text,
|
||||
disabled: this._disabled,
|
||||
getAutocompleteSuggestions: () => this._getAutocompleteSuggestions(),
|
||||
onSave: async (value) => {
|
||||
try {
|
||||
const widgetOptions = this._field.widgetOptionsJson.peek();
|
||||
if (value.trim() === '') {
|
||||
delete widgetOptions.dropdownCondition;
|
||||
} else {
|
||||
widgetOptions.dropdownCondition = {text: value};
|
||||
}
|
||||
await this._field.widgetOptionsJson.setAndSave(widgetOptions);
|
||||
} catch (e) {
|
||||
if (e?.code === 'ACL_DENY') {
|
||||
reportError(e);
|
||||
} else {
|
||||
this._saveError.set(e.message.replace(/^\[Sandbox\]/, '').trim());
|
||||
}
|
||||
}
|
||||
},
|
||||
onDispose: () => {
|
||||
this._isEditingCondition.set(false);
|
||||
},
|
||||
},
|
||||
(el) => { this._editorElement = el; },
|
||||
testId('field-dropdown-condition'),
|
||||
),
|
||||
),
|
||||
dom.maybe(this._error, (error) => cssRow(
|
||||
cssDropdownConditionError(error), testId('field-dropdown-condition-error')),
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private _getAutocompleteSuggestions(): ISuggestionWithValue[] {
|
||||
const variables = ['choice'];
|
||||
const refColumns = this._refColumns.get();
|
||||
if (refColumns) {
|
||||
variables.push('choice.id', ...refColumns.map(({colId}) => `choice.${colId.peek()}`));
|
||||
}
|
||||
const columns = this._columns.get();
|
||||
variables.push(
|
||||
...columns.map(({colId}) => `$${colId.peek()}`),
|
||||
...columns.map(({colId}) => `rec.${colId.peek()}`),
|
||||
);
|
||||
|
||||
const suggestions = [
|
||||
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
|
||||
'OWNER', 'EDITOR', 'VIEWER',
|
||||
...variables,
|
||||
];
|
||||
return suggestions.map(suggestion => [suggestion, null]);
|
||||
}
|
||||
}
|
||||
|
||||
const cssSetDropdownConditionRow = styled(cssRow, `
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
const cssDropdownConditionError = styled('div', `
|
||||
color: ${theme.errorText};
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
`);
|
@ -0,0 +1,234 @@
|
||||
import * as AceEditor from 'app/client/components/AceEditor';
|
||||
import {createGroup} from 'app/client/components/commands';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||
import {initializeAceOptions} from 'app/client/widgets/FormulaEditor';
|
||||
import {IEditorCommandGroup} from 'app/client/widgets/NewBaseEditor';
|
||||
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
||||
import {
|
||||
Computed,
|
||||
Disposable,
|
||||
dom,
|
||||
DomElementArg,
|
||||
Holder,
|
||||
IDisposableOwner,
|
||||
Observable,
|
||||
styled,
|
||||
} from 'grainjs';
|
||||
|
||||
const t = makeT('DropdownConditionEditor');
|
||||
|
||||
interface BuildDropdownConditionEditorOptions {
|
||||
value: Computed<string>;
|
||||
disabled: Computed<boolean>;
|
||||
onSave(value: string): Promise<void>;
|
||||
onDispose(): void;
|
||||
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an editor for dropdown conditions.
|
||||
*
|
||||
* Dropdown conditions are client-evaluated predicate formulas used to filter
|
||||
* items shown in autocomplete dropdowns for Choice and Reference type columns.
|
||||
*
|
||||
* Unlike Python formulas, dropdown conditions only support a very limited set of
|
||||
* features. They are a close relative of ACL formulas, sharing the same underlying
|
||||
* parser and compiler.
|
||||
*
|
||||
* See `sandbox/grist/predicate_formula.py` and `app/common/PredicateFormula.ts` for
|
||||
* more details on parsing and compiling, respectively.
|
||||
*/
|
||||
export function buildDropdownConditionEditor(
|
||||
owner: IDisposableOwner,
|
||||
options: BuildDropdownConditionEditorOptions,
|
||||
...args: DomElementArg[]
|
||||
) {
|
||||
const {value, disabled, onSave, onDispose, getAutocompleteSuggestions} = options;
|
||||
return dom.create(buildHighlightedCode,
|
||||
value,
|
||||
{maxLines: 1},
|
||||
dom.cls(cssDropdownConditionField.className),
|
||||
dom.cls('disabled'),
|
||||
cssDropdownConditionField.cls('-disabled', disabled),
|
||||
{tabIndex: '-1'},
|
||||
dom.on('focus', (_, refElem) => openDropdownConditionEditor(owner, {
|
||||
refElem,
|
||||
value,
|
||||
onSave,
|
||||
onDispose,
|
||||
getAutocompleteSuggestions,
|
||||
})),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
function openDropdownConditionEditor(owner: IDisposableOwner, options: {
|
||||
refElem: Element;
|
||||
value: Computed<string>;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
onDispose: () => void;
|
||||
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
|
||||
}) {
|
||||
const {refElem, value, onSave, onDispose, getAutocompleteSuggestions} = options;
|
||||
|
||||
const saveAndDispose = async () => {
|
||||
const editorValue = editor.getValue();
|
||||
if (editorValue !== value.get()) {
|
||||
await onSave(editorValue);
|
||||
}
|
||||
if (editor.isDisposed()) { return; }
|
||||
|
||||
editor.dispose();
|
||||
};
|
||||
|
||||
const commands: IEditorCommandGroup = {
|
||||
fieldEditCancel: () => editor.dispose(),
|
||||
fieldEditSaveHere: () => editor.blur(),
|
||||
fieldEditSave: () => editor.blur(),
|
||||
};
|
||||
|
||||
const editor = DropdownConditionEditor.create(owner, {
|
||||
editValue: value.get(),
|
||||
commands,
|
||||
onBlur: saveAndDispose,
|
||||
getAutocompleteSuggestions,
|
||||
});
|
||||
editor.attach(refElem);
|
||||
editor.onDispose(() => onDispose());
|
||||
}
|
||||
|
||||
interface DropdownConditionEditorOptions {
|
||||
editValue: string;
|
||||
commands: IEditorCommandGroup;
|
||||
onBlur(): Promise<void>;
|
||||
getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];
|
||||
}
|
||||
|
||||
class DropdownConditionEditor extends Disposable {
|
||||
private _aceEditor: any;
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement!: EditorPlacement;
|
||||
private _placementHolder = Holder.create(this);
|
||||
private _isEmpty: Computed<boolean>;
|
||||
|
||||
constructor(private _options: DropdownConditionEditorOptions) {
|
||||
super();
|
||||
|
||||
const initialValue = _options.editValue;
|
||||
const editorState = Observable.create(this, initialValue);
|
||||
|
||||
this._aceEditor = this.autoDispose(AceEditor.create({
|
||||
calcSize: this._calcSize.bind(this),
|
||||
editorState,
|
||||
getSuggestions: _options.getAutocompleteSuggestions,
|
||||
}));
|
||||
|
||||
this._isEmpty = Computed.create(this, editorState, (_use, state) => state === '');
|
||||
this.autoDispose(this._isEmpty.addListener(() => this._updateEditorPlaceholder()));
|
||||
|
||||
const commandGroup = this.autoDispose(createGroup({
|
||||
..._options.commands,
|
||||
}, this, true));
|
||||
|
||||
this._dom = cssDropdownConditionEditorWrapper(
|
||||
cssDropdownConditionEditor(
|
||||
createMobileButtons(_options.commands),
|
||||
this._aceEditor.buildDom((aceObj: any) => {
|
||||
initializeAceOptions(aceObj);
|
||||
const val = initialValue;
|
||||
const pos = val.length;
|
||||
this._aceEditor.setValue(val, pos);
|
||||
this._aceEditor.attachCommandGroup(commandGroup);
|
||||
if (val === '') {
|
||||
this._updateEditorPlaceholder();
|
||||
}
|
||||
})
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public attach(cellElem: Element): void {
|
||||
this._editorPlacement = EditorPlacement.create(this._placementHolder, this._dom, cellElem, {
|
||||
margins: getButtonMargins(),
|
||||
});
|
||||
this.autoDispose(this._editorPlacement.onReposition.addListener(this._aceEditor.resize, this._aceEditor));
|
||||
this._aceEditor.onAttach();
|
||||
this._updateEditorPlaceholder();
|
||||
this._aceEditor.resize();
|
||||
this._aceEditor.getEditor().focus();
|
||||
this._aceEditor.getEditor().on('blur', () => this._options.onBlur());
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this._aceEditor.getValue();
|
||||
}
|
||||
|
||||
public blur() {
|
||||
this._aceEditor.getEditor().blur();
|
||||
}
|
||||
|
||||
private _updateEditorPlaceholder() {
|
||||
const editor = this._aceEditor.getEditor();
|
||||
const shouldShowPlaceholder = editor.session.getValue().length === 0;
|
||||
if (editor.renderer.emptyMessageNode) {
|
||||
// Remove the current placeholder if one is present.
|
||||
editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode);
|
||||
}
|
||||
if (!shouldShowPlaceholder) {
|
||||
editor.renderer.emptyMessageNode = null;
|
||||
} else {
|
||||
editor.renderer.emptyMessageNode = cssDropdownConditionPlaceholder(t('Enter condition.'));
|
||||
editor.renderer.scroller.appendChild(editor.renderer.emptyMessageNode);
|
||||
}
|
||||
}
|
||||
|
||||
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
|
||||
const placeholder: HTMLElement | undefined = this._aceEditor.getEditor().renderer.emptyMessageNode;
|
||||
if (placeholder) {
|
||||
return this._editorPlacement.calcSizeWithPadding(elem, {
|
||||
width: placeholder.scrollWidth,
|
||||
height: placeholder.scrollHeight,
|
||||
});
|
||||
} else {
|
||||
return this._editorPlacement.calcSizeWithPadding(elem, {
|
||||
width: desiredElemSize.width,
|
||||
height: desiredElemSize.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cssDropdownConditionField = styled('div', `
|
||||
flex: auto;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
|
||||
&-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssDropdownConditionEditorWrapper = styled('div.default_editor.formula_editor_wrapper', `
|
||||
border-radius: 3px;
|
||||
`);
|
||||
|
||||
const cssDropdownConditionEditor = styled('div', `
|
||||
background-color: ${theme.aceEditorBg};
|
||||
padding: 5px;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
min-height: 22px;
|
||||
border-radius: 3px;
|
||||
`);
|
||||
|
||||
const cssDropdownConditionPlaceholder = styled('div', `
|
||||
color: ${theme.lightText};
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
`);
|
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* We allow alphanumeric characters and certain common whitelisted characters (except at the start),
|
||||
* plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be
|
||||
* more precise about what exactly to allow).
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/;
|
||||
|
||||
/**
|
||||
* Test name against various rules to check if it is a valid username.
|
||||
*/
|
||||
export function checkName(name: string): boolean {
|
||||
return VALID_NAME_REGEXP.test(name);
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly
|
||||
* relative time to now - e.g. 'yesterday', '2 days ago'.
|
||||
*/
|
||||
export function getTimeFromNow(utcDateISO: string): string
|
||||
/**
|
||||
* Given a unix timestamp (in milliseconds), gives a reader-friendly
|
||||
* relative time to now - e.g. 'yesterday', '2 days ago'.
|
||||
*/
|
||||
export function getTimeFromNow(ms: number): string
|
||||
export function getTimeFromNow(isoOrTimestamp: string|number): string {
|
||||
const time = moment.utc(isoOrTimestamp);
|
||||
const now = moment();
|
||||
const diff = now.diff(time, 's');
|
||||
if (diff < 0 && diff > -60) {
|
||||
// If the time appears to be in the future, but less than a minute
|
||||
// in the future, chalk it up to a difference in time
|
||||
// synchronization and don't claim the resource will be changed in
|
||||
// the future. For larger differences, just report them
|
||||
// literally, there's a more serious problem or lack of
|
||||
// synchronization.
|
||||
return now.fromNow();
|
||||
}
|
||||
return time.fromNow();
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
import { BootProbeIds, BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
|
||||
import { removeTrailingSlash } from 'app/common/gutil';
|
||||
import { getGristConfig } from 'app/common/urlUtils';
|
||||
import { Disposable, Observable, UseCBOwner } from 'grainjs';
|
||||
|
||||
/**
|
||||
* Manage a collection of checks about the status of Grist, for
|
||||
* presentation on the admin panel or the boot page.
|
||||
*/
|
||||
export class AdminChecks {
|
||||
|
||||
// The back end will offer a set of probes (diagnostics) we
|
||||
// can use. Probes have unique IDs.
|
||||
public probes: Observable<BootProbeInfo[]>;
|
||||
|
||||
// Keep track of probe requests we are making, by probe ID.
|
||||
private _requests: Map<string, AdminCheckRunner>;
|
||||
|
||||
// Keep track of probe results we have received, by probe ID.
|
||||
private _results: Map<string, Observable<BootProbeResult>>;
|
||||
|
||||
constructor(private _parent: Disposable) {
|
||||
this.probes = Observable.create(_parent, []);
|
||||
this._results = new Map();
|
||||
this._requests = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of available checks from the server.
|
||||
*/
|
||||
public async fetchAvailableChecks() {
|
||||
const config = getGristConfig();
|
||||
const errMessage = config.errMessage;
|
||||
if (!errMessage) {
|
||||
// Probe tool URLs are relative to the current URL. Don't trust configuration,
|
||||
// because it may be buggy if the user is here looking at the boot page
|
||||
// to figure out some problem.
|
||||
//
|
||||
// We have been careful to make URLs available with appropriate
|
||||
// middleware relative to both of the admin panel and the boot page.
|
||||
const url = new URL(removeTrailingSlash(document.location.href));
|
||||
url.pathname += '/probe';
|
||||
const resp = await fetch(url.href);
|
||||
const _probes = await resp.json();
|
||||
this.probes.set(_probes.probes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the result of one of the available checks. Returns information
|
||||
* about the check and a way to observe the result when it arrives.
|
||||
*/
|
||||
public requestCheck(probe: BootProbeInfo): AdminCheckRequest {
|
||||
const {id} = probe;
|
||||
let result = this._results.get(id);
|
||||
if (!result) {
|
||||
result = Observable.create(this._parent, {});
|
||||
this._results.set(id, result);
|
||||
}
|
||||
let request = this._requests.get(id);
|
||||
if (!request) {
|
||||
request = new AdminCheckRunner(id, this._results, this._parent);
|
||||
this._requests.set(id, request);
|
||||
}
|
||||
request.start();
|
||||
return {
|
||||
probe,
|
||||
result,
|
||||
details: probeDetails[id],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the result of a check, by its id.
|
||||
*/
|
||||
public requestCheckById(use: UseCBOwner, id: BootProbeIds): AdminCheckRequest|undefined {
|
||||
const probe = use(this.probes).find(p => p.id === id);
|
||||
if (!probe) { return; }
|
||||
return this.requestCheck(probe);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a check and a way to observe its result once available.
|
||||
*/
|
||||
export interface AdminCheckRequest {
|
||||
probe: BootProbeInfo,
|
||||
result: Observable<BootProbeResult>,
|
||||
details: ProbeDetails,
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a single check.
|
||||
*/
|
||||
export class AdminCheckRunner {
|
||||
constructor(public id: string, public results: Map<string, Observable<BootProbeResult>>,
|
||||
public parent: Disposable) {
|
||||
const url = new URL(removeTrailingSlash(document.location.href));
|
||||
url.pathname = url.pathname + '/probe/' + id;
|
||||
fetch(url.href).then(async resp => {
|
||||
const _probes: BootProbeResult = await resp.json();
|
||||
const ob = results.get(id);
|
||||
if (ob) {
|
||||
ob.set(_probes);
|
||||
}
|
||||
}).catch(e => console.error(e));
|
||||
}
|
||||
|
||||
public start() {
|
||||
let result = this.results.get(this.id);
|
||||
if (!result) {
|
||||
result = Observable.create(this.parent, {});
|
||||
this.results.set(this.id, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic information about diagnostics is kept on the server,
|
||||
* but it can be useful to show extra details and tips in the
|
||||
* client.
|
||||
*/
|
||||
const probeDetails: Record<string, ProbeDetails> = {
|
||||
'boot-page': {
|
||||
info: `
|
||||
This boot page should not be too easy to access. Either turn
|
||||
it off when configuration is ok (by unsetting GRIST_BOOT_KEY)
|
||||
or make GRIST_BOOT_KEY long and cryptographically secure.
|
||||
`,
|
||||
},
|
||||
|
||||
'health-check': {
|
||||
info: `
|
||||
Grist has a small built-in health check often used when running
|
||||
it as a container.
|
||||
`,
|
||||
},
|
||||
|
||||
'host-header': {
|
||||
info: `
|
||||
Requests arriving to Grist should have an accurate Host
|
||||
header. This is essential when GRIST_SERVE_SAME_ORIGIN
|
||||
is set.
|
||||
`,
|
||||
},
|
||||
|
||||
'system-user': {
|
||||
info: `
|
||||
It is good practice not to run Grist as the root user.
|
||||
`,
|
||||
},
|
||||
|
||||
'reachable': {
|
||||
info: `
|
||||
The main page of Grist should be available.
|
||||
`
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Information about the probe.
|
||||
*/
|
||||
export interface ProbeDetails {
|
||||
info: string;
|
||||
}
|
@ -1,36 +1,144 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import * as css from 'app/client/ui/FormPagesCss';
|
||||
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {DomContents, makeTestId} from 'grainjs';
|
||||
import {DomContents, DomElementArg, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('FormContainer');
|
||||
|
||||
const testId = makeTestId('test-form-');
|
||||
|
||||
export function buildFormContainer(buildBody: () => DomContents) {
|
||||
return css.formContainer(
|
||||
css.form(
|
||||
css.formBody(
|
||||
export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {
|
||||
return cssFormMessagePage(
|
||||
cssFormMessage(
|
||||
cssFormMessageBody(
|
||||
buildBody(),
|
||||
),
|
||||
css.formFooter(
|
||||
css.poweredByGrist(
|
||||
css.poweredByGristLink(
|
||||
{href: commonUrls.forms, target: '_blank'},
|
||||
t('Powered by'),
|
||||
css.gristLogo(),
|
||||
)
|
||||
),
|
||||
css.buildForm(
|
||||
css.buildFormLink(
|
||||
{href: commonUrls.forms, target: '_blank'},
|
||||
t('Build your own form'),
|
||||
icon('Expand'),
|
||||
),
|
||||
),
|
||||
cssFormMessageFooter(
|
||||
buildFormFooter(),
|
||||
),
|
||||
),
|
||||
testId('container'),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildFormFooter() {
|
||||
return [
|
||||
cssPoweredByGrist(
|
||||
cssPoweredByGristLink(
|
||||
{href: commonUrls.forms, target: '_blank'},
|
||||
t('Powered by'),
|
||||
cssGristLogo(),
|
||||
)
|
||||
),
|
||||
cssBuildForm(
|
||||
cssBuildFormLink(
|
||||
{href: commonUrls.forms, target: '_blank'},
|
||||
t('Build your own form'),
|
||||
icon('Expand'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const cssFormMessageImageContainer = styled('div', `
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`);
|
||||
|
||||
export const cssFormMessageImage = styled('img', `
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
export const cssFormMessageText = styled('div', `
|
||||
color: ${colors.dark};
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 24px;
|
||||
`);
|
||||
|
||||
const cssFormMessagePage = styled('div', `
|
||||
padding: 16px;
|
||||
`);
|
||||
|
||||
const cssFormMessage = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: white;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
max-width: 600px;
|
||||
margin: 0px auto;
|
||||
`);
|
||||
|
||||
const cssFormMessageBody = styled('div', `
|
||||
width: 100%;
|
||||
padding: 20px 48px 20px 48px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFormMessageFooter = styled('div', `
|
||||
border-top: 1px solid ${colors.darkGrey};
|
||||
padding: 8px 16px;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssPoweredByGrist = styled('div', `
|
||||
color: ${colors.darkText};
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px 10px;
|
||||
`);
|
||||
|
||||
const cssPoweredByGristLink = styled('a', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: ${colors.darkText};
|
||||
text-decoration: none;
|
||||
`);
|
||||
|
||||
const cssGristLogo = styled('div', `
|
||||
width: 58px;
|
||||
height: 20.416px;
|
||||
flex-shrink: 0;
|
||||
background: url(img/logo-grist.png);
|
||||
background-position: 0 0;
|
||||
background-size: contain;
|
||||
background-color: transparent;
|
||||
background-repeat: no-repeat;
|
||||
margin-top: 3px;
|
||||
`);
|
||||
|
||||
const cssBuildForm = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
`);
|
||||
|
||||
const cssBuildFormLink = styled('a', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-decoration-line: underline;
|
||||
color: ${colors.darkGreen};
|
||||
--icon-color: ${colors.darkGreen};
|
||||
`);
|
||||
|
@ -1,139 +0,0 @@
|
||||
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
export const pageContainer = styled('div', `
|
||||
background-color: ${colors.lightGrey};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 52px 0px 52px 0px;
|
||||
overflow: auto;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 20px 0px 20px 0px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const formContainer = styled('div', `
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
`);
|
||||
|
||||
export const form = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: white;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
max-width: 600px;
|
||||
margin: 0px auto;
|
||||
`);
|
||||
|
||||
export const formBody = styled('div', `
|
||||
width: 100%;
|
||||
padding: 20px 48px 20px 48px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const formMessageImageContainer = styled('div', `
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`);
|
||||
|
||||
export const formErrorMessageImageContainer = styled(formMessageImageContainer, `
|
||||
height: 281px;
|
||||
`);
|
||||
|
||||
export const formSuccessMessageImageContainer = styled(formMessageImageContainer, `
|
||||
height: 215px;
|
||||
`);
|
||||
|
||||
export const formMessageImage = styled('img', `
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
export const formErrorMessageImage = styled(formMessageImage, `
|
||||
max-height: 281px;
|
||||
max-width: 250px;
|
||||
`);
|
||||
|
||||
export const formSuccessMessageImage = styled(formMessageImage, `
|
||||
max-height: 215px;
|
||||
max-width: 250px;
|
||||
`);
|
||||
|
||||
export const formMessageText = styled('div', `
|
||||
color: ${colors.dark};
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 24px;
|
||||
`);
|
||||
|
||||
export const formFooter = styled('div', `
|
||||
border-top: 1px solid ${colors.darkGrey};
|
||||
padding: 8px 16px;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
export const poweredByGrist = styled('div', `
|
||||
color: ${colors.darkText};
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px 10px;
|
||||
`);
|
||||
|
||||
export const poweredByGristLink = styled('a', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: ${colors.darkText};
|
||||
text-decoration: none;
|
||||
`);
|
||||
|
||||
export const buildForm = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
`);
|
||||
|
||||
export const buildFormLink = styled('a', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-decoration-line: underline;
|
||||
color: ${colors.darkGreen};
|
||||
--icon-color: ${colors.darkGreen};
|
||||
`);
|
||||
|
||||
export const gristLogo = styled('div', `
|
||||
width: 58px;
|
||||
height: 20.416px;
|
||||
flex-shrink: 0;
|
||||
background: url(img/logo-grist.png);
|
||||
background-position: 0 0;
|
||||
background-size: contain;
|
||||
background-color: transparent;
|
||||
background-repeat: no-repeat;
|
||||
margin-top: 3px;
|
||||
`);
|
@ -0,0 +1,25 @@
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
export const cssRadioInput = styled('input', `
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0px !important;
|
||||
border-radius: 50%;
|
||||
background-clip: content-box;
|
||||
border: 1px solid ${theme.checkboxBorder};
|
||||
background-color: ${theme.checkboxBg};
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
border: 1px solid ${theme.checkboxBorderHover};
|
||||
}
|
||||
&:disabled {
|
||||
background-color: 1px solid ${theme.checkboxDisabledBg};
|
||||
}
|
||||
&:checked {
|
||||
padding: 2px;
|
||||
background-color: ${theme.controlPrimaryBg};
|
||||
border: 1px solid ${theme.controlPrimaryBg};
|
||||
}
|
||||
`);
|
@ -0,0 +1,191 @@
|
||||
import { createPausableObs, PausableObservable } from 'app/client/lib/pausableObs';
|
||||
import { getStorage } from 'app/client/lib/storage';
|
||||
import { urlState } from 'app/client/models/gristUrlState';
|
||||
import { Theme, ThemeAppearance, ThemeColors, ThemePrefs } from 'app/common/ThemePrefs';
|
||||
import { getThemeColors } from 'app/common/Themes';
|
||||
import { getGristConfig } from 'app/common/urlUtils';
|
||||
import { Computed, Observable } from 'grainjs';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
const DEFAULT_LIGHT_THEME: Theme = {appearance: 'light', colors: getThemeColors('GristLight')};
|
||||
const DEFAULT_DARK_THEME: Theme = {appearance: 'dark', colors: getThemeColors('GristDark')};
|
||||
|
||||
/**
|
||||
* A singleton observable for the current user's Grist theme preferences.
|
||||
*
|
||||
* Set by `AppModel`, which populates it from `UserPrefs`.
|
||||
*/
|
||||
export const gristThemePrefs = Observable.create<ThemePrefs | null>(null, null);
|
||||
|
||||
/**
|
||||
* Returns `true` if the user agent prefers a dark color scheme.
|
||||
*/
|
||||
export function prefersColorSchemeDark(): boolean {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
let _prefersColorSchemeDarkObs: PausableObservable<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Returns a singleton observable for whether the user agent prefers a
|
||||
* dark color scheme.
|
||||
*/
|
||||
export function prefersColorSchemeDarkObs(): PausableObservable<boolean> {
|
||||
if (!_prefersColorSchemeDarkObs) {
|
||||
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const obs = createPausableObs<boolean>(null, query.matches);
|
||||
query.addEventListener('change', event => obs.set(event.matches));
|
||||
_prefersColorSchemeDarkObs = obs;
|
||||
}
|
||||
return _prefersColorSchemeDarkObs;
|
||||
}
|
||||
|
||||
let _gristThemeObs: Computed<Theme> | undefined;
|
||||
|
||||
/**
|
||||
* A singleton observable for the current Grist theme.
|
||||
*/
|
||||
export function gristThemeObs() {
|
||||
if (!_gristThemeObs) {
|
||||
_gristThemeObs = Computed.create(null, (use) => {
|
||||
// Custom CSS is incompatible with custom themes.
|
||||
if (getGristConfig().enableCustomCss) { return DEFAULT_LIGHT_THEME; }
|
||||
|
||||
// If a user's preference is known, return it.
|
||||
const themePrefs = use(gristThemePrefs);
|
||||
const userAgentPrefersDarkTheme = use(prefersColorSchemeDarkObs());
|
||||
if (themePrefs) { return getThemeFromPrefs(themePrefs, userAgentPrefersDarkTheme); }
|
||||
|
||||
// Otherwise, fall back to the user agent's preference.
|
||||
return userAgentPrefersDarkTheme ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME;
|
||||
});
|
||||
}
|
||||
return _gristThemeObs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the current theme's CSS variables to the document, and
|
||||
* re-attaches them whenever the theme changes.
|
||||
*/
|
||||
export function attachTheme() {
|
||||
// Custom CSS is incompatible with custom themes.
|
||||
if (getGristConfig().enableCustomCss) { return; }
|
||||
|
||||
// Attach the current theme's variables to the DOM.
|
||||
attachCssThemeVars(gristThemeObs().get());
|
||||
|
||||
// Whenever the theme changes, re-attach its variables to the DOM.
|
||||
gristThemeObs().addListener((newTheme, oldTheme) => {
|
||||
if (isEqual(newTheme, oldTheme)) { return; }
|
||||
|
||||
attachCssThemeVars(newTheme);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `Theme` from the given `themePrefs`.
|
||||
*
|
||||
* If theme query parameters are present (`themeName`, `themeAppearance`, `themeSyncWithOs`),
|
||||
* they will take precedence over their respective values in `themePrefs`.
|
||||
*/
|
||||
function getThemeFromPrefs(themePrefs: ThemePrefs, userAgentPrefersDarkTheme: boolean): Theme {
|
||||
let {appearance, syncWithOS} = themePrefs;
|
||||
|
||||
const urlParams = urlState().state.get().params;
|
||||
if (urlParams?.themeAppearance) {
|
||||
appearance = urlParams?.themeAppearance;
|
||||
}
|
||||
if (urlParams?.themeSyncWithOs !== undefined) {
|
||||
syncWithOS = urlParams?.themeSyncWithOs;
|
||||
}
|
||||
|
||||
if (syncWithOS) {
|
||||
appearance = userAgentPrefersDarkTheme ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
let nameOrColors = themePrefs.colors[appearance];
|
||||
if (urlParams?.themeName) {
|
||||
nameOrColors = urlParams?.themeName;
|
||||
}
|
||||
|
||||
let colors: ThemeColors;
|
||||
if (typeof nameOrColors === 'string') {
|
||||
colors = getThemeColors(nameOrColors);
|
||||
} else {
|
||||
colors = nameOrColors;
|
||||
}
|
||||
|
||||
return {appearance, colors};
|
||||
}
|
||||
|
||||
function attachCssThemeVars({appearance, colors: themeColors}: Theme) {
|
||||
// Prepare the custom properties needed for applying the theme.
|
||||
const properties = Object.entries(themeColors)
|
||||
.map(([name, value]) => `--grist-theme-${name}: ${value};`);
|
||||
|
||||
// Include properties for styling the scrollbar.
|
||||
properties.push(...getCssThemeScrollbarProperties(appearance));
|
||||
|
||||
// Include properties for picking an appropriate background image.
|
||||
properties.push(...getCssThemeBackgroundProperties(appearance));
|
||||
|
||||
// Apply the properties to the theme style element.
|
||||
getOrCreateStyleElement('grist-theme').textContent = `:root {
|
||||
${properties.join('\n')}
|
||||
}`;
|
||||
|
||||
// Make the browser aware of the color scheme.
|
||||
document.documentElement.style.setProperty(`color-scheme`, appearance);
|
||||
|
||||
// Cache the appearance in local storage; this is currently used to apply a suitable
|
||||
// background image that's shown while the application is loading.
|
||||
getStorage().setItem('appearance', appearance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets scrollbar-related css properties that are appropriate for the given `appearance`.
|
||||
*
|
||||
* Note: Browser support for customizing scrollbars is still a mixed bag; the bulk of customization
|
||||
* is non-standard and unsupported by Firefox. If support matures, we could expose some of these in
|
||||
* custom themes, but for now we'll just go with reasonable presets.
|
||||
*/
|
||||
function getCssThemeScrollbarProperties(appearance: ThemeAppearance) {
|
||||
return [
|
||||
'--scroll-bar-fg: ' +
|
||||
(appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'),
|
||||
'--scroll-bar-hover-fg: ' +
|
||||
(appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'),
|
||||
'--scroll-bar-active-fg: ' +
|
||||
(appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'),
|
||||
'--scroll-bar-bg: ' +
|
||||
(appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets background-related css properties that are appropriate for the given `appearance`.
|
||||
*
|
||||
* Currently, this sets a property for showing a background image that's visible while a page
|
||||
* is loading.
|
||||
*/
|
||||
function getCssThemeBackgroundProperties(appearance: ThemeAppearance) {
|
||||
const value = appearance === 'dark'
|
||||
? 'url("img/prismpattern.png")'
|
||||
: 'url("img/gplaypattern.png")';
|
||||
return [`--grist-theme-bg: ${value};`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a style element in the head of the document with the given `id`.
|
||||
*
|
||||
* Useful for grouping CSS values such as theme custom properties without needing to
|
||||
* pollute the document with in-line styles.
|
||||
*/
|
||||
function getOrCreateStyleElement(id: string) {
|
||||
let style = document.head.querySelector(`#${id}`);
|
||||
if (style) { return style; }
|
||||
style = document.createElement('style');
|
||||
style.setAttribute('id', id);
|
||||
document.head.append(style);
|
||||
return style;
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {clamp, numberOrDefault} from 'app/common/gutil';
|
||||
import {MaybePromise} from 'app/plugin/gutil';
|
||||
import {BindableValue, dom, DomElementArg, IDomArgs, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-numeric-spinner-');
|
||||
|
||||
export interface NumericSpinnerOptions {
|
||||
/** Defaults to `false`. */
|
||||
setValueOnInput?: boolean;
|
||||
label?: string;
|
||||
defaultValue?: number | Observable<number>;
|
||||
/** No minimum if unset. */
|
||||
minValue?: number;
|
||||
/** No maximum if unset. */
|
||||
maxValue?: number;
|
||||
disabled?: BindableValue<boolean>;
|
||||
inputArgs?: IDomArgs<HTMLInputElement>;
|
||||
/** Called on blur and spinner button click. */
|
||||
save?: (val?: number) => MaybePromise<void>,
|
||||
}
|
||||
|
||||
export function numericSpinner(
|
||||
value: Observable<number | ''>,
|
||||
options: NumericSpinnerOptions = {},
|
||||
...args: DomElementArg[]
|
||||
) {
|
||||
const {
|
||||
setValueOnInput = false,
|
||||
label,
|
||||
defaultValue,
|
||||
minValue = Number.NEGATIVE_INFINITY,
|
||||
maxValue = Number.POSITIVE_INFINITY,
|
||||
disabled,
|
||||
inputArgs = [],
|
||||
save,
|
||||
} = options;
|
||||
|
||||
const getDefaultValue = () => {
|
||||
if (defaultValue === undefined) {
|
||||
return 0;
|
||||
} else if (typeof defaultValue === 'number') {
|
||||
return defaultValue;
|
||||
} else {
|
||||
return defaultValue.get();
|
||||
}
|
||||
};
|
||||
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
const shiftValue = async (delta: 1 | -1, opts: {saveValue?: boolean} = {}) => {
|
||||
const {saveValue} = opts;
|
||||
const currentValue = numberOrDefault(inputElement.value, getDefaultValue());
|
||||
const newValue = clamp(Math.floor(currentValue + delta), minValue, maxValue);
|
||||
if (setValueOnInput) { value.set(newValue); }
|
||||
if (saveValue) { await save?.(newValue); }
|
||||
return newValue;
|
||||
};
|
||||
const incrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(1, opts);
|
||||
const decrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(-1, opts);
|
||||
|
||||
return cssNumericSpinner(
|
||||
disabled ? cssNumericSpinner.cls('-disabled', disabled) : null,
|
||||
label ? cssNumLabel(label) : null,
|
||||
inputElement = cssNumInput(
|
||||
{type: 'number'},
|
||||
dom.prop('value', value),
|
||||
defaultValue !== undefined ? dom.prop('placeholder', defaultValue) : null,
|
||||
dom.onKeyDown({
|
||||
ArrowUp: async (_ev, elem) => { elem.value = String(await incrementValue()); },
|
||||
ArrowDown: async (_ev, elem) => { elem.value = String(await decrementValue()); },
|
||||
Enter$: async (_ev, elem) => save && elem.blur(),
|
||||
}),
|
||||
!setValueOnInput ? null : dom.on('input', (_ev, elem) => {
|
||||
value.set(Number.parseFloat(elem.value));
|
||||
}),
|
||||
!save ? null : dom.on('blur', async () => {
|
||||
let newValue = numberOrDefault(inputElement.value, undefined);
|
||||
if (newValue !== undefined) { newValue = clamp(newValue, minValue, maxValue); }
|
||||
await save(newValue);
|
||||
}),
|
||||
dom.on('focus', (_ev, elem) => elem.select()),
|
||||
...inputArgs,
|
||||
),
|
||||
cssSpinner(
|
||||
cssSpinnerBtn(
|
||||
cssSpinnerTop('DropdownUp'),
|
||||
dom.on('click', async () => incrementValue({saveValue: true})),
|
||||
testId('increment'),
|
||||
),
|
||||
cssSpinnerBtn(
|
||||
cssSpinnerBottom('Dropdown'),
|
||||
dom.on('click', async () => decrementValue({saveValue: true})),
|
||||
testId('decrement'),
|
||||
),
|
||||
),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
const cssNumericSpinner = styled('div', `
|
||||
position: relative;
|
||||
flex: auto;
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
outline: 1px solid ${theme.inputBorder};
|
||||
background-color: ${theme.inputBg};
|
||||
border-radius: 3px;
|
||||
&-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssNumLabel = styled('div', `
|
||||
color: ${theme.lightText};
|
||||
flex-shrink: 0;
|
||||
padding-left: 8px;
|
||||
pointer-events: none;
|
||||
`);
|
||||
|
||||
const cssNumInput = styled('input', `
|
||||
flex-grow: 1;
|
||||
padding: 4px 32px 4px 8px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
appearance: none;
|
||||
color: ${theme.inputFg};
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSpinner = styled('div', `
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
|
||||
const cssSpinnerBtn = styled('div', `
|
||||
--icon-color: ${theme.controlSecondaryFg};
|
||||
flex: 1 1 0px;
|
||||
min-height: 0px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
--icon-color: ${theme.controlSecondaryHoverFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSpinnerTop = styled(icon, `
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
`);
|
||||
|
||||
const cssSpinnerBottom = styled(icon, `
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
`);
|
@ -0,0 +1,477 @@
|
||||
import { DocData } from 'app/common/DocData';
|
||||
import { getSetMapValue } from 'app/common/gutil';
|
||||
import { SchemaTypes } from 'app/common/schema';
|
||||
import { ShareOptions } from 'app/common/ShareOptions';
|
||||
import { MetaRowRecord, MetaTableData } from 'app/common/TableData';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
|
||||
/**
|
||||
* For special shares, we need to refer to resources that may not
|
||||
* be listed in the _grist_ACLResources table, and have rules that
|
||||
* aren't backed by storage in _grist_ACLRules. So we implement
|
||||
* a small helper to add an overlay of extra resources and rules.
|
||||
* They are distinguishable from real, stored resources and rules
|
||||
* by having negative IDs.
|
||||
*/
|
||||
export class TableWithOverlay<T extends keyof SchemaTypes> {
|
||||
private _extraRecords = new Array<MetaRowRecord<T>>();
|
||||
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
|
||||
private _excludedRecordIds = new Set<number>();
|
||||
private _nextFreeVirtualId: number = -1;
|
||||
|
||||
public constructor(private _originalTable: MetaTableData<T>) {}
|
||||
|
||||
// Add a record to the table, but only as an overlay - no
|
||||
// persistent changes are made. Uses negative row IDs.
|
||||
// Returns the ID assigned to the record. The passed in
|
||||
// record is expected to have an ID of zero.
|
||||
public addRecord(rec: MetaRowRecord<T>): number {
|
||||
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
|
||||
const id = this._nextFreeVirtualId;
|
||||
const recWithCorrectId: MetaRowRecord<T> = {...rec, id};
|
||||
this._extraRecords.push({...rec, id});
|
||||
this._extraRecordsById.set(id, recWithCorrectId);
|
||||
this._nextFreeVirtualId--;
|
||||
return id;
|
||||
}
|
||||
|
||||
public excludeRecord(id: number) {
|
||||
this._excludedRecordIds.add(id);
|
||||
}
|
||||
|
||||
// Support the few MetaTableData methods we actually use
|
||||
// in ACLRulesReader.
|
||||
|
||||
public getRecord(id: number) {
|
||||
if (this._excludedRecordIds.has(id)) { return undefined; }
|
||||
|
||||
if (id < 0) {
|
||||
// Reroute negative IDs to our local stash of records.
|
||||
return this._extraRecordsById.get(id);
|
||||
} else {
|
||||
// Everything else, we just pass along.
|
||||
return this._originalTable.getRecord(id);
|
||||
}
|
||||
}
|
||||
|
||||
public getRecords() {
|
||||
return this._filterExcludedRecords([
|
||||
...this._originalTable.getRecords(),
|
||||
...this._extraRecords,
|
||||
]);
|
||||
}
|
||||
|
||||
public filterRecords(properties: Partial<MetaRowRecord<T>>): Array<MetaRowRecord<T>> {
|
||||
const originalRecords = this._originalTable.filterRecords(properties);
|
||||
const extraRecords = this._extraRecords.filter((rec) => Object.keys(properties)
|
||||
.every((p) => isEqual((rec as any)[p], (properties as any)[p])));
|
||||
return this._filterExcludedRecords([...originalRecords, ...extraRecords]);
|
||||
}
|
||||
|
||||
public findMatchingRowId(properties: Partial<MetaRowRecord<T>>): number {
|
||||
const rowId = (
|
||||
this._originalTable.findMatchingRowId(properties) ||
|
||||
this._extraRecords.find((rec) => Object.keys(properties).every((p) =>
|
||||
isEqual((rec as any)[p], (properties as any)[p]))
|
||||
)?.id
|
||||
);
|
||||
return rowId && !this._excludedRecordIds.has(rowId) ? rowId : 0;
|
||||
}
|
||||
|
||||
private _filterExcludedRecords(records: MetaRowRecord<T>[]) {
|
||||
return records.filter(({id}) => !this._excludedRecordIds.has(id));
|
||||
}
|
||||
}
|
||||
|
||||
export interface ACLRulesReaderOptions {
|
||||
/**
|
||||
* Adds virtual rules for all shares in the document.
|
||||
*
|
||||
* If set to `true` and there are shares in the document, regular rules are
|
||||
* modified so that they don't apply when a document is being accessed through
|
||||
* a share, and new rules are added to grant access to the resources specified by
|
||||
* the shares.
|
||||
*
|
||||
* This will also "split" any resources (and their rules) if they apply to multiple
|
||||
* resources. Splitting produces copies of the original resource and rules
|
||||
* rules, but with modifications in place so that each copy applies to a single
|
||||
* resource. Normalizing the original rules in this way allows for a simpler mechanism
|
||||
* to override the original rules/resources with share rules, for situations where a
|
||||
* share needs to grant access to a resource that is protected by access rules (shares
|
||||
* and access rules are mutually exclusive at this time).
|
||||
*
|
||||
* Note: a value of `true` will *not* cause any persistent modifications to be made to
|
||||
* rules; all changes are "virtual" in the sense that they are applied on top of the
|
||||
* persisted rules to enable shares.
|
||||
*
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
addShareRules?: boolean;
|
||||
}
|
||||
|
||||
interface ShareContext {
|
||||
shareRef: number;
|
||||
sections: MetaRowRecord<"_grist_Views_section">[];
|
||||
columns: MetaRowRecord<"_grist_Tables_column">[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for reading ACL rules from DocData.
|
||||
*/
|
||||
export class ACLRulesReader {
|
||||
private _resourcesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLResources'));
|
||||
private _rulesTable = new TableWithOverlay(this.docData.getMetaTable('_grist_ACLRules'));
|
||||
private _sharesTable = this.docData.getMetaTable('_grist_Shares');
|
||||
private _hasShares = this._options.addShareRules && this._sharesTable.numRecords() > 0;
|
||||
/** Maps 'tableId:colId' to the comma-separated list of column IDs from the associated resource. */
|
||||
private _resourceColIdsByTableAndColId: Map<string, string> = new Map();
|
||||
|
||||
public constructor(public docData: DocData, private _options: ACLRulesReaderOptions = {}) {
|
||||
this._addOriginalRules();
|
||||
this._maybeAddShareRules();
|
||||
}
|
||||
|
||||
public entries() {
|
||||
const rulesByResourceId = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
|
||||
for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) {
|
||||
// If we have "virtual" rules to implement shares, then regular
|
||||
// rules need to be tweaked so that they don't apply when the
|
||||
// share is active.
|
||||
if (this._hasShares && rule.id >= 0) {
|
||||
disableRuleInShare(rule);
|
||||
}
|
||||
|
||||
getSetMapValue(rulesByResourceId, rule.resource, () => []).push(rule);
|
||||
}
|
||||
return rulesByResourceId.entries();
|
||||
}
|
||||
|
||||
public getResourceById(id: number) {
|
||||
return this._resourcesTable.getRecord(id);
|
||||
}
|
||||
|
||||
private _addOriginalRules() {
|
||||
for (const rule of sortBy(this._rulesTable.getRecords(), 'rulePos')) {
|
||||
const resource = this.getResourceById(rule.resource);
|
||||
if (!resource) {
|
||||
throw new Error(`ACLRule ${rule.id} refers to an invalid ACLResource ${rule.resource}`);
|
||||
}
|
||||
|
||||
if (resource.tableId !== '*' && resource.colIds !== '*') {
|
||||
const colIds = resource.colIds.split(',');
|
||||
if (colIds.length === 1) { continue; }
|
||||
|
||||
for (const colId of colIds) {
|
||||
this._resourceColIdsByTableAndColId.set(`${resource.tableId}:${colId}`, resource.colIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _maybeAddShareRules() {
|
||||
if (!this._hasShares) { return; }
|
||||
|
||||
for (const share of this._sharesTable.getRecords()) {
|
||||
this._addRulesForShare(share);
|
||||
}
|
||||
this._addDefaultShareRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add any rules needed for the specified share.
|
||||
*
|
||||
* The only kind of share we support for now is form endpoint
|
||||
* sharing.
|
||||
*/
|
||||
private _addRulesForShare(share: MetaRowRecord<'_grist_Shares'>) {
|
||||
// TODO: Unpublished shares could and should be blocked earlier,
|
||||
// by home server
|
||||
const {publish}: ShareOptions = JSON.parse(share.options || '{}');
|
||||
if (!publish) {
|
||||
this._blockShare(share.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Let's go looking for sections related to the share.
|
||||
// It was decided that the relationship between sections and
|
||||
// shares is via pages. Every section on a given page can belong
|
||||
// to at most one share.
|
||||
// Ignore sections which do not have `publish` set to `true` in
|
||||
// `shareOptions`.
|
||||
const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({
|
||||
shareRef: share.id,
|
||||
});
|
||||
const parentViews = new Set(pages.map(page => page.viewRef));
|
||||
const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter(
|
||||
section => {
|
||||
if (!parentViews.has(section.parentId)) { return false; }
|
||||
const options = JSON.parse(section.shareOptions || '{}');
|
||||
return Boolean(options.publish) && Boolean(options.form);
|
||||
}
|
||||
);
|
||||
|
||||
const sectionIds = new Set(sections.map(section => section.id));
|
||||
const fields = this.docData.getMetaTable('_grist_Views_section_field').getRecords().filter(
|
||||
field => {
|
||||
return sectionIds.has(field.parentId);
|
||||
}
|
||||
);
|
||||
const columnIds = new Set(fields.map(field => field.colRef));
|
||||
const columns = this.docData.getMetaTable('_grist_Tables_column').getRecords().filter(
|
||||
column => {
|
||||
return columnIds.has(column.id);
|
||||
}
|
||||
);
|
||||
|
||||
const tableRefs = new Set(sections.map(section => section.tableRef));
|
||||
const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter(
|
||||
table => tableRefs.has(table.id)
|
||||
);
|
||||
|
||||
// For tables associated with forms, allow creation of records,
|
||||
// and reading of referenced columns.
|
||||
// TODO: tighten access control on creation since it may be broader
|
||||
// than users expect - hidden columns could be written.
|
||||
for (const table of tables) {
|
||||
this._shareTableForForm(table, {
|
||||
shareRef: share.id, sections, columns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When accessing a document via a share, by default no user tables are
|
||||
* accessible. Everything added to the share gives additional
|
||||
* access, and never reduces access, making it easy to grant
|
||||
* access to multiple parts of the document.
|
||||
*
|
||||
* We do leave access unchanged for metadata tables, since they are
|
||||
* censored via an alternative mechanism.
|
||||
*/
|
||||
private _addDefaultShareRules() {
|
||||
// Block access to each table.
|
||||
const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords()
|
||||
.map(table => table.tableId)
|
||||
.filter(tableId => !tableId.startsWith('_grist_'))
|
||||
.sort();
|
||||
for (const tableId of tableIds) {
|
||||
this._addShareRule(this._findOrAddResource({tableId, colIds: '*'}), '-CRUDS');
|
||||
}
|
||||
|
||||
// Block schema access at the default level.
|
||||
this._addShareRule(this._findOrAddResource({tableId: '*', colIds: '*'}), '-S');
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow creating records in a table.
|
||||
*/
|
||||
private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>,
|
||||
shareContext: ShareContext) {
|
||||
const { shareRef } = shareContext;
|
||||
const resource = this._findOrAddResource({
|
||||
tableId: table.tableId,
|
||||
colIds: '*', // At creation, allow all columns to be
|
||||
// initialized.
|
||||
});
|
||||
let aclFormula = `user.ShareRef == ${shareRef}`;
|
||||
let aclFormulaParsed = JSON.stringify([
|
||||
'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
[ 'Const', shareRef ] ]);
|
||||
this._rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '+C',
|
||||
}));
|
||||
|
||||
// This is a hack to grant read schema access, needed for forms -
|
||||
// Should not be needed once forms are actually available, but
|
||||
// until them is very handy to allow using the web client to
|
||||
// submit records.
|
||||
aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`;
|
||||
aclFormulaParsed = JSON.stringify(
|
||||
[ 'And',
|
||||
[ 'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
['Const', shareRef] ],
|
||||
[ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]);
|
||||
this._rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
|
||||
}));
|
||||
|
||||
this._shareTableReferencesForForm(table, shareContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give read access to referenced columns.
|
||||
*/
|
||||
private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>,
|
||||
shareContext: ShareContext) {
|
||||
const { shareRef } = shareContext;
|
||||
|
||||
const tables = this.docData.getMetaTable('_grist_Tables');
|
||||
const columns = this.docData.getMetaTable('_grist_Tables_column');
|
||||
const tableColumns = shareContext.columns.filter(c =>
|
||||
c.parentId === table.id &&
|
||||
(c.type.startsWith('Ref:') || c.type.startsWith('RefList:')));
|
||||
for (const column of tableColumns) {
|
||||
const visibleColRef = column.visibleCol;
|
||||
// This could be blank in tests, not sure about real life.
|
||||
if (!visibleColRef) { continue; }
|
||||
const visibleCol = columns.getRecord(visibleColRef);
|
||||
if (!visibleCol) { continue; }
|
||||
const referencedTable = tables.getRecord(visibleCol.parentId);
|
||||
if (!referencedTable) { continue; }
|
||||
|
||||
const tableId = referencedTable.tableId;
|
||||
const colId = visibleCol.colId;
|
||||
const resourceColIds = this._resourceColIdsByTableAndColId.get(`${tableId}:${colId}`) ?? colId;
|
||||
const maybeResourceId = this._resourcesTable.findMatchingRowId({tableId, colIds: resourceColIds});
|
||||
if (maybeResourceId !== 0) {
|
||||
this._maybeSplitResourceForShares(maybeResourceId);
|
||||
}
|
||||
const resource = this._findOrAddResource({tableId, colIds: colId});
|
||||
const aclFormula = `user.ShareRef == ${shareRef}`;
|
||||
const aclFormulaParsed = JSON.stringify(
|
||||
[ 'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
['Const', shareRef] ]);
|
||||
this._rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a resource into multiple resources that are suitable for being
|
||||
* overridden by shares. Rules are copied to each resource, with modifications
|
||||
* that disable them in shares.
|
||||
*
|
||||
* Ignores resources for single columns, and resources created for shares
|
||||
* (i.e. those with a negative ID); the former can already be overridden
|
||||
* by shares without any additional work, and the latter are guaranteed to
|
||||
* only be for single columns.
|
||||
*
|
||||
* The motivation for this method is to normalize document access rules so
|
||||
* that rule sets apply to at most a single column. Document shares may
|
||||
* automatically grant limited access to parts of a document, such as columns
|
||||
* that are referenced from a form field. But for this to happen, extra rules
|
||||
* first need to be added to the original or new resource, which requires looking
|
||||
* up the resource by column ID to see if it exists. This lookup only works if
|
||||
* the rule set of the resource is for a single column; otherwise, the lookup
|
||||
* will fail and cause a new resource to be created, which consequently causes
|
||||
* 2 resources to exist that both contain the same column. Since this is an
|
||||
* unsupported scenario with ambiguous evaluation semantics, we pre-emptively call
|
||||
* this method to avoid such scenarios altogether.
|
||||
*/
|
||||
private _maybeSplitResourceForShares(resourceId: number) {
|
||||
if (resourceId < 0) { return; }
|
||||
|
||||
const resource = this.getResourceById(resourceId);
|
||||
if (!resource) {
|
||||
throw new Error(`Unable to find ACLResource with ID ${resourceId}`);
|
||||
}
|
||||
|
||||
const {tableId} = resource;
|
||||
const colIds = resource.colIds.split(',');
|
||||
if (colIds.length === 1) { return; }
|
||||
|
||||
const rules = sortBy(this._rulesTable.filterRecords({resource: resourceId}), 'rulePos')
|
||||
.map(r => disableRuleInShare(r));
|
||||
// Prepare a new resource for each column, with copies of the original resource's rules.
|
||||
for (const colId of colIds) {
|
||||
const newResourceId = this._resourcesTable.addRecord({id: 0, tableId, colIds: colId});
|
||||
for (const rule of rules) {
|
||||
this._rulesTable.addRecord({...rule, id: 0, resource: newResourceId});
|
||||
}
|
||||
}
|
||||
// Exclude the original resource and rules.
|
||||
this._resourcesTable.excludeRecord(resourceId);
|
||||
for (const rule of rules) {
|
||||
this._rulesTable.excludeRecord(rule.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a resource we need, and return its rowId. The resource is
|
||||
* added if it is not already present.
|
||||
*/
|
||||
private _findOrAddResource(properties: {
|
||||
tableId: string,
|
||||
colIds: string,
|
||||
}): number {
|
||||
const resource = this._resourcesTable.findMatchingRowId(properties);
|
||||
if (resource !== 0) { return resource; }
|
||||
return this._resourcesTable.addRecord({
|
||||
id: 0,
|
||||
...properties,
|
||||
});
|
||||
}
|
||||
|
||||
private _addShareRule(resourceRef: number, permissionsText: string) {
|
||||
const aclFormula = 'user.ShareRef is not None';
|
||||
const aclFormulaParsed = JSON.stringify([
|
||||
'NotEq',
|
||||
['Attr', ['Name', 'user'], 'ShareRef'],
|
||||
['Const', null],
|
||||
]);
|
||||
this._rulesTable.addRecord(this._makeRule({
|
||||
resource: resourceRef, aclFormula, aclFormulaParsed, permissionsText,
|
||||
}));
|
||||
}
|
||||
|
||||
private _blockShare(shareRef: number) {
|
||||
const resource = this._findOrAddResource({
|
||||
tableId: '*', colIds: '*',
|
||||
});
|
||||
const aclFormula = `user.ShareRef == ${shareRef}`;
|
||||
const aclFormulaParsed = JSON.stringify(
|
||||
[ 'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
['Const', shareRef] ]);
|
||||
this._rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
|
||||
}));
|
||||
}
|
||||
|
||||
private _makeRule(options: {
|
||||
resource: number,
|
||||
aclFormula: string,
|
||||
aclFormulaParsed: string,
|
||||
permissionsText: string,
|
||||
}): MetaRowRecord<'_grist_ACLRules'> {
|
||||
const {resource, aclFormula, aclFormulaParsed, permissionsText} = options;
|
||||
return {
|
||||
id: 0,
|
||||
resource,
|
||||
aclFormula,
|
||||
aclFormulaParsed,
|
||||
memo: '',
|
||||
permissionsText,
|
||||
userAttributes: '',
|
||||
rulePos: 0,
|
||||
|
||||
// The following fields are unused and deprecated.
|
||||
aclColumn: 0,
|
||||
permissions: 0,
|
||||
principals: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the ACL formula of `rule` such that it's disabled if a document is being
|
||||
* accessed via a share.
|
||||
*
|
||||
* Modifies `rule` in place.
|
||||
*/
|
||||
function disableRuleInShare(rule: MetaRowRecord<'_grist_ACLRules'>) {
|
||||
const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
|
||||
const newAclFormulaParsed = [
|
||||
'And',
|
||||
[ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ],
|
||||
aclFormulaParsed || [ 'Const', true ]
|
||||
];
|
||||
rule.aclFormula = 'user.ShareRef is None and (' + String(rule.aclFormula || 'True') + ')';
|
||||
rule.aclFormulaParsed = JSON.stringify(newAclFormulaParsed);
|
||||
return rule;
|
||||
}
|
@ -1,296 +0,0 @@
|
||||
import { DocData } from 'app/common/DocData';
|
||||
import { SchemaTypes } from 'app/common/schema';
|
||||
import { ShareOptions } from 'app/common/ShareOptions';
|
||||
import { MetaRowRecord, MetaTableData } from 'app/common/TableData';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
/**
|
||||
* For special shares, we need to refer to resources that may not
|
||||
* be listed in the _grist_ACLResources table, and have rules that
|
||||
* aren't backed by storage in _grist_ACLRules. So we implement
|
||||
* a small helper to add an overlay of extra resources and rules.
|
||||
* They are distinguishable from real, stored resources and rules
|
||||
* by having negative IDs.
|
||||
*/
|
||||
export class TableWithOverlay<T extends keyof SchemaTypes> {
|
||||
private _extraRecords = new Array<MetaRowRecord<T>>();
|
||||
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
|
||||
private _nextFreeVirtualId: number = -1;
|
||||
|
||||
public constructor(private _originalTable: MetaTableData<T>) {}
|
||||
|
||||
// Add a record to the table, but only as an overlay - no
|
||||
// persistent changes are made. Uses negative row IDs.
|
||||
// Returns the ID assigned to the record. The passed in
|
||||
// record is expected to have an ID of zero.
|
||||
public addRecord(rec: MetaRowRecord<T>): number {
|
||||
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
|
||||
const id = this._nextFreeVirtualId;
|
||||
const recWithCorrectId: MetaRowRecord<T> = {...rec, id};
|
||||
this._extraRecords.push({...rec, id});
|
||||
this._extraRecordsById.set(id, recWithCorrectId);
|
||||
this._nextFreeVirtualId--;
|
||||
return id;
|
||||
}
|
||||
|
||||
// Support the few MetaTableData methods we actually use
|
||||
// in ACLRuleCollection and ACLShareRules.
|
||||
|
||||
public getRecord(resourceId: number) {
|
||||
// Reroute negative IDs to our local stash of records.
|
||||
if (resourceId < 0) {
|
||||
return this._extraRecordsById.get(resourceId);
|
||||
}
|
||||
// Everything else, we just pass along.
|
||||
return this._originalTable.getRecord(resourceId);
|
||||
}
|
||||
|
||||
public getRecords() {
|
||||
return [...this._originalTable.getRecords(), ...this._extraRecords];
|
||||
}
|
||||
|
||||
public findMatchingRowId(properties: Partial<MetaRowRecord<T>>): number {
|
||||
// Check stored records.
|
||||
const rowId = this._originalTable.findMatchingRowId(properties);
|
||||
if (rowId) { return rowId; }
|
||||
// Check overlay.
|
||||
return this._extraRecords.find((rec) =>
|
||||
Object.keys(properties).every((p) => isEqual(
|
||||
(rec as any)[p],
|
||||
(properties as any)[p])))?.id || 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for managing special share rules.
|
||||
*/
|
||||
export class ACLShareRules {
|
||||
|
||||
public constructor(
|
||||
public docData: DocData,
|
||||
public resourcesTable: TableWithOverlay<'_grist_ACLResources'>,
|
||||
public rulesTable: TableWithOverlay<'_grist_ACLRules'>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Add any rules needed for the specified share.
|
||||
*
|
||||
* The only kind of share we support for now is form endpoint
|
||||
* sharing.
|
||||
*/
|
||||
public addRulesForShare(shareRef: number, shareOptions: ShareOptions) {
|
||||
// TODO: Unpublished shares could and should be blocked earlier,
|
||||
// by home server
|
||||
if (!shareOptions.publish) {
|
||||
this._blockShare(shareRef);
|
||||
return;
|
||||
}
|
||||
|
||||
// Let's go looking for sections related to the share.
|
||||
// It was decided that the relationship between sections and
|
||||
// shares is via pages. Every section on a given page can belong
|
||||
// to at most one share.
|
||||
// Ignore sections which do not have `publish` set to `true` in
|
||||
// `shareOptions`.
|
||||
const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({
|
||||
shareRef,
|
||||
});
|
||||
const parentViews = new Set(pages.map(page => page.viewRef));
|
||||
const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter(
|
||||
section => {
|
||||
if (!parentViews.has(section.parentId)) { return false; }
|
||||
const options = JSON.parse(section.shareOptions || '{}');
|
||||
return Boolean(options.publish) && Boolean(options.form);
|
||||
}
|
||||
);
|
||||
|
||||
const tableRefs = new Set(sections.map(section => section.tableRef));
|
||||
const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter(
|
||||
table => tableRefs.has(table.id)
|
||||
);
|
||||
|
||||
// For tables associated with forms, allow creation of records,
|
||||
// and reading of referenced columns.
|
||||
// TODO: should probably be limiting to a set of columns associated
|
||||
// with section - but for form widget that could potentially be very
|
||||
// confusing since it may not be easy to see that certain columns
|
||||
// haven't been made visible for it? For now, just working at table
|
||||
// level.
|
||||
for (const table of tables) {
|
||||
this._shareTableForForm(table, shareRef);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When accessing a document via a share, by default no user tables are
|
||||
* accessible. Everything added to the share gives additional
|
||||
* access, and never reduces access, making it easy to grant
|
||||
* access to multiple parts of the document.
|
||||
*
|
||||
* We do leave access unchanged for metadata tables, since they are
|
||||
* censored via an alternative mechanism.
|
||||
*/
|
||||
public addDefaultRulesForShares() {
|
||||
const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords()
|
||||
.map(table => table.tableId)
|
||||
.filter(tableId => !tableId.startsWith('_grist_'))
|
||||
.sort();
|
||||
for (const tableId of tableIds) {
|
||||
const resource = this._findOrAddResource({
|
||||
tableId, colIds: '*',
|
||||
});
|
||||
const aclFormula = `user.ShareRef is not None`;
|
||||
const aclFormulaParsed = JSON.stringify([
|
||||
'NotEq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
['Const', null] ]);
|
||||
this.rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When accessing a document via a share, any regular granular access
|
||||
* rules should not apply. This requires an extra conditional.
|
||||
*/
|
||||
public transformNonShareRules(state: {
|
||||
rule: MetaRowRecord<'_grist_ACLRules'>,
|
||||
aclFormulaParsed: object,
|
||||
}) {
|
||||
state.rule.aclFormula = 'user.ShareRef is None and (' + String(state.rule.aclFormula || 'True') + ')';
|
||||
state.aclFormulaParsed = [
|
||||
'And',
|
||||
[ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ],
|
||||
state.aclFormulaParsed || [ 'Const', true ]
|
||||
];
|
||||
state.rule.aclFormulaParsed = JSON.stringify(state.aclFormulaParsed);
|
||||
return state.aclFormulaParsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow creating records in a table.
|
||||
*/
|
||||
private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>,
|
||||
shareRef: number) {
|
||||
const resource = this._findOrAddResource({
|
||||
tableId: table.tableId,
|
||||
colIds: '*',
|
||||
});
|
||||
let aclFormula = `user.ShareRef == ${shareRef}`;
|
||||
let aclFormulaParsed = JSON.stringify([
|
||||
'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
[ 'Const', shareRef ] ]);
|
||||
this.rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '+C',
|
||||
}));
|
||||
|
||||
// This is a hack to grant read schema access, needed for forms -
|
||||
// Should not be needed once forms are actually available, but
|
||||
// until them is very handy to allow using the web client to
|
||||
// submit records.
|
||||
aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`;
|
||||
aclFormulaParsed = JSON.stringify(
|
||||
[ 'And',
|
||||
[ 'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
['Const', shareRef] ],
|
||||
[ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]);
|
||||
this.rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
|
||||
}));
|
||||
|
||||
this._shareTableReferencesForForm(table, shareRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give read access to referenced columns.
|
||||
*/
|
||||
private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>,
|
||||
shareRef: number) {
|
||||
const tables = this.docData.getMetaTable('_grist_Tables');
|
||||
const columns = this.docData.getMetaTable('_grist_Tables_column');
|
||||
const tableColumns = columns.filterRecords({
|
||||
parentId: table.id,
|
||||
}).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:'));
|
||||
for (const column of tableColumns) {
|
||||
const visibleColRef = column.visibleCol;
|
||||
// This could be blank in tests, not sure about real life.
|
||||
if (!visibleColRef) { continue; }
|
||||
const visibleCol = columns.getRecord(visibleColRef);
|
||||
if (!visibleCol) { continue; }
|
||||
const referencedTable = tables.getRecord(visibleCol.parentId);
|
||||
if (!referencedTable) { continue; }
|
||||
|
||||
const tableId = referencedTable.tableId;
|
||||
const colId = visibleCol.colId;
|
||||
const resource = this._findOrAddResource({
|
||||
tableId: tableId,
|
||||
colIds: colId,
|
||||
});
|
||||
const aclFormula = `user.ShareRef == ${shareRef}`;
|
||||
const aclFormulaParsed = JSON.stringify(
|
||||
[ 'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
['Const', shareRef] ]);
|
||||
this.rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a resource we need, and return its rowId. The resource is
|
||||
* added if it is not already present.
|
||||
*/
|
||||
private _findOrAddResource(properties: {
|
||||
tableId: string,
|
||||
colIds: string,
|
||||
}): number {
|
||||
const resource = this.resourcesTable.findMatchingRowId(properties);
|
||||
if (resource !== 0) { return resource; }
|
||||
return this.resourcesTable.addRecord({
|
||||
id: 0,
|
||||
...properties,
|
||||
});
|
||||
}
|
||||
|
||||
private _blockShare(shareRef: number) {
|
||||
const resource = this._findOrAddResource({
|
||||
tableId: '*', colIds: '*',
|
||||
});
|
||||
const aclFormula = `user.ShareRef == ${shareRef}`;
|
||||
const aclFormulaParsed = JSON.stringify(
|
||||
[ 'Eq',
|
||||
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
|
||||
['Const', shareRef] ]);
|
||||
this.rulesTable.addRecord(this._makeRule({
|
||||
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
|
||||
}));
|
||||
}
|
||||
|
||||
private _makeRule(options: {
|
||||
resource: number,
|
||||
aclFormula: string,
|
||||
aclFormulaParsed: string,
|
||||
permissionsText: string,
|
||||
}): MetaRowRecord<'_grist_ACLRules'> {
|
||||
const {resource, aclFormula, aclFormulaParsed, permissionsText} = options;
|
||||
return {
|
||||
id: 0,
|
||||
resource,
|
||||
aclFormula,
|
||||
aclFormulaParsed,
|
||||
memo: '',
|
||||
permissionsText,
|
||||
userAttributes: '',
|
||||
rulePos: 0,
|
||||
|
||||
// The following fields are unused and deprecated.
|
||||
aclColumn: 0,
|
||||
permissions: 0,
|
||||
principals: '',
|
||||
};
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue