2024-03-23 17:11:06 +00:00
import { buildHomeBanners } from 'app/client/components/Banners' ;
import { makeT } from 'app/client/lib/localization' ;
2024-04-29 14:54:03 +00:00
import { localStorageJsonObs } from 'app/client/lib/localStorageObs' ;
import { getTimeFromNow } from 'app/client/lib/timeUtils' ;
2024-05-01 22:17:26 +00:00
import { AdminChecks , probeDetails , ProbeDetails } from 'app/client/models/AdminChecks' ;
2024-04-30 00:31:10 +00:00
import { AppModel , getHomeUrl , reportError } from 'app/client/models/AppModel' ;
2024-03-23 17:11:06 +00:00
import { urlState } from 'app/client/models/gristUrlState' ;
import { AppHeader } from 'app/client/ui/AppHeader' ;
import { leftPanelBasic } from 'app/client/ui/LeftPanelCommon' ;
import { pagePanels } from 'app/client/ui/PagePanels' ;
import { SupportGristPage } from 'app/client/ui/SupportGristPage' ;
import { createTopBarHome } from 'app/client/ui/TopBar' ;
import { cssBreadcrumbs , separator } from 'app/client/ui2018/breadcrumbs' ;
2024-04-29 14:54:03 +00:00
import { basicButton } from 'app/client/ui2018/buttons' ;
import { toggle } from 'app/client/ui2018/checkbox' ;
2024-03-23 17:11:06 +00:00
import { mediaSmall , testId , theme , vars } from 'app/client/ui2018/cssVars' ;
2024-04-29 14:54:03 +00:00
import { cssLink , makeLinks } from 'app/client/ui2018/links' ;
2024-05-01 22:17:26 +00:00
import { BootProbeInfo , BootProbeResult , SandboxingBootProbeDetails } from 'app/common/BootProbe' ;
2024-04-30 00:31:10 +00:00
import { commonUrls , getPageTitleSuffix } from 'app/common/gristUrls' ;
2024-04-29 14:54:03 +00:00
import { InstallAPI , InstallAPIImpl , LatestVersion } from 'app/common/InstallAPI' ;
import { naturalCompare } from 'app/common/SortFunc' ;
import { getGristConfig } from 'app/common/urlUtils' ;
import * as version from 'app/common/version' ;
2024-05-10 12:54:37 +00:00
import { Computed , Disposable , dom , IDisposable ,
2024-04-29 14:54:03 +00:00
IDisposableOwner , MultiHolder , Observable , styled } from 'grainjs' ;
2024-05-10 12:54:37 +00:00
import { AdminSection , AdminSectionItem , HidableToggle } from 'app/client/ui/AdminPanelCss' ;
2024-04-29 14:54:03 +00:00
2024-03-23 17:11:06 +00:00
const t = makeT ( 'AdminPanel' ) ;
// Translated "Admin Panel" name, made available to other modules.
export function getAdminPanelName() {
return t ( "Admin Panel" ) ;
}
export class AdminPanel extends Disposable {
private _supportGrist = SupportGristPage . create ( this , this . _appModel ) ;
2024-04-29 14:54:03 +00:00
private readonly _installAPI : InstallAPI = new InstallAPIImpl ( getHomeUrl ( ) ) ;
2024-04-30 00:31:10 +00:00
private _checks : AdminChecks ;
2024-03-23 17:11:06 +00:00
constructor ( private _appModel : AppModel ) {
super ( ) ;
document . title = getAdminPanelName ( ) + getPageTitleSuffix ( getGristConfig ( ) ) ;
2024-05-01 22:17:26 +00:00
this . _checks = new AdminChecks ( this , this . _installAPI ) ;
2024-03-23 17:11:06 +00:00
}
public buildDom() {
2024-04-30 00:31:10 +00:00
this . _checks . fetchAvailableChecks ( ) . catch ( err = > {
reportError ( err ) ;
} ) ;
2024-03-23 17:11:06 +00:00
const panelOpen = Observable . create ( this , false ) ;
return pagePanels ( {
leftPanel : {
panelWidth : Observable.create ( this , 240 ) ,
panelOpen ,
hideOpener : true ,
header : dom.create ( AppHeader , this . _appModel ) ,
content : leftPanelBasic ( this . _appModel , panelOpen ) ,
} ,
headerMain : this._buildMainHeader ( ) ,
contentTop : buildHomeBanners ( this . _appModel ) ,
contentMain : dom.create ( this . _buildMainContent . bind ( this ) ) ,
} ) ;
}
private _buildMainHeader() {
return dom . frag (
cssBreadcrumbs ( { style : 'margin-left: 16px;' } ,
cssLink (
urlState ( ) . setLinkUrl ( { } ) ,
t ( 'Home' ) ,
) ,
separator ( ' / ' ) ,
dom ( 'span' , getAdminPanelName ( ) ) ,
) ,
createTopBarHome ( this . _appModel ) ,
) ;
}
2024-04-29 14:54:03 +00:00
private _buildMainContent ( owner : MultiHolder ) {
2024-05-01 22:17:26 +00:00
// If probes are available, show the panel as normal.
// Otherwise say it is unavailable, and describe a fallback
// mechanism for access.
2024-03-23 17:11:06 +00:00
return cssPageContainer (
dom . cls ( 'clipboard' ) ,
{ tabIndex : "-1" } ,
2024-05-01 22:17:26 +00:00
dom . maybe ( this . _checks . probes , probes = > {
return probes . length > 0
? this . _buildMainContentForAdmin ( owner )
: this . _buildMainContentForOthers ( owner ) ;
} ) ,
testId ( 'admin-panel' ) ,
) ;
}
/ * *
* Show something helpful to those without access to the panel ,
* which could include a legit adminstrator if auth is misconfigured .
* /
private _buildMainContentForOthers ( owner : MultiHolder ) {
return dom . create ( AdminSection , t ( 'Administrator Panel Unavailable' ) , [
2024-05-16 18:00:24 +00:00
dom ( 'p' , t ( ` You do not have access to the administrator panel.
Please log in as an administrator . ` )),
dom ( 'p' , t ( ` Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}} ` ) , { bootKey : dom ( 'pre' , 'GRIST_BOOT_KEY=secret' ) , url : dom ( 'pre' , ` /admin?key=secret ` ) } ) ,
2024-05-01 22:17:26 +00:00
] ) ;
}
private _buildMainContentForAdmin ( owner : MultiHolder ) {
return [
2024-05-10 12:54:37 +00:00
dom . create ( AdminSection , t ( 'Support Grist' ) , [
dom . create ( AdminSectionItem , {
2024-03-23 17:11:06 +00:00
id : 'telemetry' ,
name : t ( 'Telemetry' ) ,
description : t ( 'Help us make Grist better' ) ,
2024-05-10 12:54:37 +00:00
value : dom.create ( HidableToggle , this . _supportGrist . getTelemetryOptInObservable ( ) ) ,
2024-03-23 17:11:06 +00:00
expandedContent : this._supportGrist.buildTelemetrySection ( ) ,
} ) ,
2024-05-10 12:54:37 +00:00
dom . create ( AdminSectionItem , {
2024-03-23 17:11:06 +00:00
id : 'sponsor' ,
name : t ( 'Sponsor' ) ,
description : t ( 'Support Grist Labs on GitHub' ) ,
value : this._supportGrist.buildSponsorshipSmallButton ( ) ,
expandedContent : this._supportGrist.buildSponsorshipSection ( ) ,
} ) ,
2024-05-10 12:54:37 +00:00
] ) ,
dom . create ( AdminSection , t ( 'Security Settings' ) , [
dom . create ( AdminSectionItem , {
2024-04-30 00:31:10 +00:00
id : 'sandboxing' ,
name : t ( 'Sandboxing' ) ,
description : t ( 'Sandbox settings for data engine' ) ,
value : this._buildSandboxingDisplay ( owner ) ,
expandedContent : this._buildSandboxingNotice ( ) ,
} ) ,
2024-05-16 17:09:38 +00:00
dom . create ( AdminSectionItem , {
id : 'authentication' ,
name : t ( 'Authentication' ) ,
description : t ( 'Current authentication method' ) ,
value : this._buildAuthenticationDisplay ( owner ) ,
expandedContent : this._buildAuthenticationNotice ( owner ) ,
} )
2024-05-10 12:54:37 +00:00
] ) ,
dom . create ( AdminSection , t ( 'Version' ) , [
dom . create ( AdminSectionItem , {
2024-03-23 17:11:06 +00:00
id : 'version' ,
name : t ( 'Current' ) ,
description : t ( 'Current version of Grist' ) ,
value : cssValueLabel ( ` Version ${ version . version } ` ) ,
} ) ,
2024-04-29 14:54:03 +00:00
this . _buildUpdates ( owner ) ,
2024-05-10 12:54:37 +00:00
] ) ,
2024-05-01 22:17:26 +00:00
dom . create ( AdminSection , t ( 'Self Checks' ) , [
this . _buildProbeItems ( owner , {
showRedundant : false ,
showNovel : true ,
} ) ,
dom . create ( AdminSectionItem , {
id : 'probe-other' ,
name : 'more...' ,
description : '' ,
value : '' ,
expandedContent : this._buildProbeItems ( owner , {
showRedundant : true ,
showNovel : false ,
} ) ,
} ) ,
] ) ,
] ;
2024-03-23 17:11:06 +00:00
}
2024-04-30 00:31:10 +00:00
private _buildSandboxingDisplay ( owner : IDisposableOwner ) {
return dom . domComputed (
use = > {
const req = this . _checks . requestCheckById ( use , 'sandboxing' ) ;
const result = req ? use ( req . result ) : undefined ;
const success = result ? . success ;
const details = result ? . details as SandboxingBootProbeDetails | undefined ;
if ( ! details ) {
return cssValueLabel ( t ( 'unknown' ) ) ;
}
const flavor = details . flavor ;
const configured = details . configured ;
return cssValueLabel (
configured ?
2024-05-10 12:54:37 +00:00
( success ? cssHappyText ( t ( 'OK' ) + ` : ${ flavor } ` ) :
cssErrorText ( t ( 'Error' ) + ` : ${ flavor } ` ) ) :
cssErrorText ( t ( 'unconfigured' ) ) ) ;
2024-04-30 00:31:10 +00:00
}
) ;
}
private _buildSandboxingNotice() {
return [
2024-05-01 22:17:26 +00:00
// Use AdminChecks text for sandboxing, in order not to
// duplicate.
probeDetails [ 'sandboxing' ] . info ,
2024-04-30 00:31:10 +00:00
dom (
'div' ,
{ style : 'margin-top: 8px' } ,
cssLink ( { href : commonUrls.helpSandboxing , target : '_blank' } , t ( 'Learn more.' ) )
) ,
] ;
}
2024-05-16 17:09:38 +00:00
private _buildAuthenticationDisplay ( owner : IDisposableOwner ) {
return dom . domComputed (
use = > {
const req = this . _checks . requestCheckById ( use , 'authentication' ) ;
const result = req ? use ( req . result ) : undefined ;
if ( ! result ) {
return cssValueLabel ( cssErrorText ( 'unavailable' ) ) ;
}
const { success , details } = result ;
const loginSystemId = details ? . loginSystemId ;
if ( ! success || ! loginSystemId ) {
return cssValueLabel ( cssErrorText ( 'auth error' ) ) ;
}
if ( loginSystemId === 'no-logins' ) {
return cssValueLabel ( cssDangerText ( 'no authentication' ) ) ;
}
return cssValueLabel ( cssHappyText ( loginSystemId ) ) ;
}
) ;
}
private _buildAuthenticationNotice ( owner : IDisposableOwner ) {
return t ( ' Grist allows different types of authentication to be configured , including SAML and OIDC . \
We recommend enabling one of these if Grist is accessible over the network or being made available \
to multiple people . ' ) ;
}
2024-04-29 14:54:03 +00:00
private _buildUpdates ( owner : MultiHolder ) {
// We can be in those states:
enum State {
// Never checked before (no last version or last check time).
// Shows "No information available" [Check now]
NEVER ,
// Did check previously, but it was a while ago, user should press the button to check.
// Shows "Last checked X days ago" [Check now]
STALE ,
// In the middle of checking for updates.
CHECKING ,
// Transient state, shown after Check now is clicked.
// Grist is up to date (state only shown after a successful check), or even upfront.
// Won't be shown after page is reloaded.
// Shows "Checking for updates..."
CURRENT ,
// A newer version is available. Can be shown after reload if last
// version that was checked is newer than the current version.
// Shows "Newer version available" [version]
AVAILABLE ,
// Error occurred during this check. If the error occurred during last check
// it is not stored.
// Shows "Error checking for updates" [Check now]
ERROR ,
}
// Are updates enabled at all.
const defaultValue = {
onLoad : false ,
lastCheckDate : null as number | null ,
lastVersion : null as string | null ,
} ;
const prop = < T extends keyof typeof defaultValue > ( key : T ) = > {
const computed = Computed . create ( owner , ( use ) = > use ( settings ) [ key ] ) ;
computed . onWrite ( ( val ) = > settings . set ( { . . . settings . get ( ) , [ key ] : val } ) ) ;
return computed as Observable < typeof defaultValue [ T ] > ;
} ;
const settings = owner . autoDispose ( localStorageJsonObs ( 'new-version-check' , defaultValue ) ) ;
const onLoad = prop ( 'onLoad' ) ;
const latestVersion = prop ( 'lastVersion' ) ;
const lastCheckDate = prop ( 'lastCheckDate' ) ;
const comparison = Computed . create ( owner , ( use ) = > {
const versions = [ version . version , use ( latestVersion ) ] ;
if ( ! versions [ 1 ] ) {
return null ;
}
// Sort them in natural order, so that "1.10" comes after "1.9".
versions . sort ( naturalCompare ) . reverse ( ) ;
if ( versions [ 0 ] === version . version ) {
return 'old' ;
} else {
return 'new' ;
}
} ) ;
// Observable state of the updates check.
const state : Observable < State > = Observable . create ( owner , State . NEVER ) ;
// The background task that checks for updates, can be disposed (cancelled) when needed.
let backgroundTask : IDisposable | null = null ;
// By default we link to the GitHub releases page, but the endpoint might say something different.
let releaseURL = 'https://github.com/gristlabs/grist-core/releases' ;
// All the events that might occur
const actions = {
checkForUpdates : async ( ) = > {
state . set ( State . CHECKING ) ;
latestVersion . set ( null ) ;
// We can be disabled, why the check is in progress.
const controller = new AbortController ( ) ;
backgroundTask = {
dispose() {
if ( controller . signal . aborted ) { return ; }
backgroundTask = null ;
controller . abort ( ) ;
}
} ;
owner . autoDispose ( backgroundTask ) ;
try {
const result = await this . _installAPI . checkUpdates ( ) ;
if ( controller . signal . aborted ) { return ; }
actions . gotLatestVersion ( result ) ;
} catch ( err ) {
if ( controller . signal . aborted ) { return ; }
state . set ( State . ERROR ) ;
reportError ( err ) ;
}
} ,
disableAutoCheck : ( ) = > {
backgroundTask ? . dispose ( ) ;
backgroundTask = null ;
onLoad . set ( false ) ;
} ,
enableAutoCheck : ( ) = > {
onLoad . set ( true ) ;
if ( state . get ( ) !== State . CHECKING && state . get ( ) !== State . AVAILABLE ) {
actions . checkForUpdates ( ) . catch ( reportError ) ;
}
} ,
gotLatestVersion : ( data : LatestVersion ) = > {
lastCheckDate . set ( Date . now ( ) ) ;
latestVersion . set ( data . latestVersion ) ;
releaseURL = data . updateURL || releaseURL ;
const result = comparison . get ( ) ;
switch ( result ) {
case 'old' : state . set ( State . CURRENT ) ; break ;
case 'new' : state . set ( State . AVAILABLE ) ; break ;
// This should not happen, but if it does, we should show the error.
default : state . set ( State . ERROR ) ; break ;
}
}
} ;
const description = Computed . create ( owner , ( use ) = > {
switch ( use ( state ) ) {
case State.NEVER : return t ( 'No information available' ) ;
case State.CHECKING : return '⌛ ' + t ( 'Checking for updates...' ) ;
case State.CURRENT : return '✅ ' + t ( 'Grist is up to date' ) ;
case State.AVAILABLE : return t ( 'Newer version available' ) ;
case State.ERROR : return '❌ ' + t ( 'Error checking for updates' ) ;
case State . STALE : {
const lastCheck = use ( lastCheckDate ) ;
return t ( 'Last checked {{time}}' , { time : lastCheck ? getTimeFromNow ( lastCheck ) : 'n/a' } ) ;
}
}
} ) ;
// Now trigger the initial state, by checking if we should auto-check.
if ( onLoad . get ( ) ) {
actions . checkForUpdates ( ) . catch ( reportError ) ;
} else {
if ( comparison . get ( ) === 'new' ) {
state . set ( State . AVAILABLE ) ;
} else if ( comparison . get ( ) === 'old' ) {
state . set ( State . STALE ) ;
} else {
state . set ( State . NEVER ) ; // default one.
}
}
// Toggle component operates on a boolean observable, without a way to set the value. So
// create a controller for it to intercept the write and call the appropriate action.
const enabledController = Computed . create ( owner , ( use ) = > use ( onLoad ) ) ;
enabledController . onWrite ( ( val ) = > {
if ( val ) {
actions . enableAutoCheck ( ) ;
} else {
actions . disableAutoCheck ( ) ;
}
} ) ;
const upperCheckNowVisible = Computed . create ( owner , ( use ) = > {
switch ( use ( state ) ) {
case State . CHECKING :
case State . CURRENT :
case State . AVAILABLE :
return false ;
default :
return true ;
}
} ) ;
2024-05-10 12:54:37 +00:00
return dom . create ( AdminSectionItem , {
2024-04-29 14:54:03 +00:00
id : 'updates' ,
name : t ( 'Updates' ) ,
description : dom ( 'span' , testId ( 'admin-panel-updates-message' ) , dom . text ( description ) ) ,
value : cssValueButton (
dom . domComputed ( use = > {
if ( use ( state ) === State . CHECKING ) {
return null ;
}
if ( use ( upperCheckNowVisible ) ) {
return basicButton (
t ( 'Check now' ) ,
dom . on ( 'click' , actions . checkForUpdates ) ,
testId ( 'admin-panel-updates-upper-check-now' )
) ;
}
if ( use ( latestVersion ) ) {
return cssValueLabel ( ` Version ${ use ( latestVersion ) } ` , testId ( 'admin-panel-updates-version' ) ) ;
}
throw new Error ( 'Invalid state' ) ;
} )
) ,
expandedContent : cssColumns (
cssColumn (
cssColumn . cls ( '-left' ) ,
dom ( 'div' , t ( 'Grist releases are at ' ) , makeLinks ( releaseURL ) ) ,
dom . maybe ( lastCheckDate , ms = > dom ( 'div' ,
dom ( 'span' , t ( 'Last checked {{time}}' , { time : getTimeFromNow ( ms ) } ) ) ,
dom ( 'span' , ' ' ) ,
// Format date in local format.
cssGrayed ( new Date ( ms ) . toLocaleString ( ) ) ,
) ) ,
dom ( 'div' , t ( 'Auto-check when this page loads' ) ) ,
) ,
cssColumn (
cssColumn . cls ( '-right' ) ,
// `Check now` button, only shown when auto checks are enabled and we are not in the
// middle of checking. Otherwise the button is shown in the summary row, and there is
// no need to duplicate it.
dom . maybe ( use = > ! use ( upperCheckNowVisible ) , ( ) = > [
cssCheckNowButton (
t ( 'Check now' ) ,
testId ( 'admin-panel-updates-lower-check-now' ) ,
dom . on ( 'click' , actions . checkForUpdates ) ,
dom . prop ( 'disabled' , use = > use ( state ) === State . CHECKING ) ,
) ,
] ) ,
toggle ( enabledController , testId ( 'admin-panel-updates-auto-check' ) ) ,
) ,
)
} ) ;
}
2024-05-01 22:17:26 +00:00
/ * *
* Show the results of various checks . Of the checks , some are considered
* "redundant" ( already covered elsewhere in the Admin Panel ) and the
* remainder are "novel" .
* /
private _buildProbeItems ( owner : MultiHolder , options : {
showRedundant : boolean ,
showNovel : boolean ,
} ) {
return dom . domComputed (
use = > [
. . . use ( this . _checks . probes ) . map ( probe = > {
const isRedundant = probe . id === 'sandboxing' ;
const show = isRedundant ? options.showRedundant : options.showNovel ;
if ( ! show ) { return null ; }
const req = this . _checks . requestCheck ( probe ) ;
return this . _buildProbeItem ( owner , req . probe , use ( req . result ) , req . details ) ;
} ) ,
]
) ;
}
/ * *
* Show the result of an individual check .
* /
private _buildProbeItem ( owner : MultiHolder ,
info : BootProbeInfo ,
result : BootProbeResult ,
details : ProbeDetails | undefined ) {
2024-05-15 20:17:57 +00:00
const status = this . _encodeSuccess ( result ) ;
2024-05-01 22:17:26 +00:00
return dom . create ( AdminSectionItem , {
id : ` probe- ${ info . id } ` ,
name : info.id ,
description : info.name ,
value : cssStatus ( status ) ,
expandedContent : [
cssCheckHeader (
2024-05-13 20:25:24 +00:00
t ( 'Results' ) ,
2024-05-01 22:17:26 +00:00
{ style : 'margin-top: 0px; padding-top: 0px;' } ,
) ,
result . verdict ? dom ( 'pre' , result . verdict ) : null ,
( result . success === undefined ) ? null :
dom ( 'p' ,
2024-05-13 20:25:24 +00:00
result . success ? t ( 'Check succeeded.' ) : t ( 'Check failed.' ) ) ,
2024-05-01 22:17:26 +00:00
( result . done !== true ) ? null :
2024-05-13 20:25:24 +00:00
dom ( 'p' , t ( 'No fault detected.' ) ) ,
2024-05-01 22:17:26 +00:00
( details ? . info === undefined ) ? null : [
2024-05-13 20:25:24 +00:00
cssCheckHeader ( t ( 'Notes' ) ) ,
2024-05-01 22:17:26 +00:00
details . info ,
] ,
( result . details === undefined ) ? null : [
2024-05-13 20:25:24 +00:00
cssCheckHeader ( t ( 'Details' ) ) ,
2024-05-01 22:17:26 +00:00
. . . Object . entries ( result . details ) . map ( ( [ key , val ] ) = > {
return dom (
'div' ,
cssLabel ( key ) ,
dom ( 'input' , dom . prop (
'value' ,
typeof val === 'string' ? val : JSON.stringify ( val ) ) ) ) ;
} ) ,
] ,
] ,
} ) ;
}
2024-05-15 20:17:57 +00:00
/ * *
* Give an icon summarizing success or failure . Factor in the
* severity of the result for failures . This is crude , the
* visualization of the results can be elaborated in future .
* /
private _encodeSuccess ( result : BootProbeResult ) {
if ( result . success === undefined ) { return '―' ; }
if ( result . success ) { return '✅' ; }
if ( result . severity === 'warning' ) { return '❗' ; }
if ( result . severity === 'hmm' ) { return '?' ; }
// remaining case is a fault.
return '❌' ;
}
2024-03-23 17:11:06 +00:00
}
2024-05-01 22:17:26 +00:00
// Ugh I'm not a front end person. h5 small-caps, sure why not.
// Hopefully someone with taste will edit someday!
const cssCheckHeader = styled ( 'h5' , `
margin - bottom : 5px ;
font - variant : small - caps ;
` );
const cssStatus = styled ( 'div' , `
display : inline - block ;
text - align : center ;
width : 40px ;
padding : 5px ;
` );
2024-03-23 17:11:06 +00:00
const cssPageContainer = styled ( 'div' , `
overflow : auto ;
padding : 40px ;
font - size : $ { vars . introFontSize } ;
color : $ { theme . text } ;
@media $ { mediaSmall } {
& {
padding : 0px ;
font - size : $ { vars . mediumFontSize } ;
}
}
` );
2024-05-10 12:54:37 +00:00
export const cssValueLabel = styled ( 'div' , `
2024-03-23 17:11:06 +00:00
padding : 4px 8 px ;
color : $ { theme . text } ;
border : 1px solid $ { theme . inputBorder } ;
border - radius : $ { vars . controlBorderRadius } ;
2024-04-29 14:54:03 +00:00
` );
// A wrapper for the version details panel. Shows two columns.
// First grows as needed, second shrinks as needed and is aligned to the bottom.
const cssColumns = styled ( 'div' , `
display : flex ;
align - items : flex - end ;
& > div :first - child {
flex - grow : 1 ;
flex - shrink : 0 ;
}
& > div :last - child {
flex - shrink : 1 ;
}
` );
const cssColumn = styled ( 'div' , `
display : flex ;
flex - direction : column ;
gap : 8px ;
flex - grow : 1 ;
flex - shrink : 1 ;
margin - block : 1px ; /* otherwise toggle is squashed: TODO: -1px in toggle looks like a bug */
& - left {
align - items : flex - start ;
}
& - right {
align - items : flex - end ;
justify - content : flex - end ;
}
` );
const cssValueButton = styled ( 'div' , `
height : 30px ;
` );
const cssCheckNowButton = styled ( basicButton , `
& - hidden {
visibility : hidden ;
}
` );
const cssGrayed = styled ( 'span' , `
color : $ { theme . lightText } ;
2024-03-23 17:11:06 +00:00
` );
2024-04-30 00:31:10 +00:00
2024-05-10 12:54:37 +00:00
const cssErrorText = styled ( 'span' , `
2024-04-30 00:31:10 +00:00
color : $ { theme . errorText } ;
` );
2024-05-16 17:09:38 +00:00
export const cssDangerText = styled ( 'div' , `
color : $ { theme . dangerText } ;
` );
2024-05-10 12:54:37 +00:00
const cssHappyText = styled ( 'span' , `
2024-04-30 00:31:10 +00:00
color : $ { theme . controlFg } ;
` );
2024-05-01 22:17:26 +00:00
export const cssLabel = styled ( 'div' , `
display : inline - block ;
min - width : 100px ;
text - align : right ;
padding - right : 5px ;
` );