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 {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 {icon} from 'app/client/ui2018/icons';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
import {DomContents, makeTestId} from 'grainjs';
|
import {DomContents, DomElementArg, styled} from 'grainjs';
|
||||||
|
|
||||||
const t = makeT('FormContainer');
|
const t = makeT('FormContainer');
|
||||||
|
|
||||||
const testId = makeTestId('test-form-');
|
export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {
|
||||||
|
return cssFormMessagePage(
|
||||||
export function buildFormContainer(buildBody: () => DomContents) {
|
cssFormMessage(
|
||||||
return css.formContainer(
|
cssFormMessageBody(
|
||||||
css.form(
|
|
||||||
css.formBody(
|
|
||||||
buildBody(),
|
buildBody(),
|
||||||
),
|
),
|
||||||
css.formFooter(
|
cssFormMessageFooter(
|
||||||
css.poweredByGrist(
|
buildFormFooter(),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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