add: account creation.

feat: mocking options.
This commit is contained in:
Darshan
2026-01-24 20:09:56 +05:30
parent eb8abcfe63
commit 0eda9e6315
20 changed files with 1101 additions and 218 deletions
+65 -5
View File
@@ -19,9 +19,9 @@ if ($installMode === 'mock' && $defaultEmailCertificates === '') {
}
$step = isset($_GET['step']) ? (int) $_GET['step'] : 1;
$step = max(1, min(4, $step));
if ($isUpgrade && $step === 2) {
$step = 3;
$step = max(1, min(5, $step));
if ($isUpgrade && ($step === 2 || $step === 3)) {
$step = 4;
}
$partialFile = __DIR__ . "/installer/templates/steps/step-{$step}.phtml";
@@ -34,7 +34,7 @@ if (isset($_GET['partial'])) {
exit;
}
$cardStep = min(3, $step);
$cardStep = min(4, $step);
$stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml";
if (!is_file($stepFile)) {
$stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml";
@@ -43,6 +43,8 @@ $cssVersion = @filemtime(__DIR__ . '/installer/css/styles.css') ?: time();
$tooltipsVersion = @filemtime(__DIR__ . '/installer/js/tooltips.js') ?: time();
$constantsVersion = @filemtime(__DIR__ . '/installer/js/constants.js') ?: time();
$stepsContextVersion = @filemtime(__DIR__ . '/installer/js/modules/context.js') ?: time();
$mockModuleVersion = @filemtime(__DIR__ . '/installer/js/modules/mock.js') ?: time();
$mockControlsVersion = @filemtime(__DIR__ . '/installer/js/modules/mock-controls.js') ?: time();
$stepsStateVersion = @filemtime(__DIR__ . '/installer/js/modules/state.js') ?: time();
$stepsValidationVersion = @filemtime(__DIR__ . '/installer/js/modules/validation.js') ?: time();
$stepsUiVersion = @filemtime(__DIR__ . '/installer/js/modules/ui.js') ?: time();
@@ -64,6 +66,10 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time()
<script src="installer/js/constants.js?v=<?php echo $constantsVersion; ?>" defer></script>
<!-- Load context first; other modules depend on InstallerStepsContext. -->
<script src="installer/js/modules/context.js?v=<?php echo $stepsContextVersion; ?>" defer></script>
<script src="installer/js/modules/mock.js?v=<?php echo $mockModuleVersion; ?>" defer></script>
<?php if ($installMode === 'mock') { ?>
<script src="installer/js/modules/mock-controls.js?v=<?php echo $mockControlsVersion; ?>" defer></script>
<?php } ?>
<script src="installer/js/modules/state.js?v=<?php echo $stepsStateVersion; ?>" defer></script>
<script src="installer/js/modules/validation.js?v=<?php echo $stepsValidationVersion; ?>" defer></script>
<script src="installer/js/modules/ui.js?v=<?php echo $stepsUiVersion; ?>" defer></script>
@@ -127,6 +133,10 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time()
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
</span>
<span class="step-indicator" data-step="4">
<span class="indicator-active"><?php include __DIR__ . '/installer/icons/indicator-active.svg'; ?></span>
<span class="indicator-inactive"><?php include __DIR__ . '/installer/icons/indicator-inactive.svg'; ?></span>
</span>
</div>
<button class="button primary" type="button" data-action="next">
@@ -137,7 +147,7 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time()
</div>
<div class="install-screen" aria-live="polite">
<div class="install-screen-content">
<?php if ($step === 4) { include __DIR__ . '/installer/templates/steps/step-4.phtml'; } ?>
<?php if ($step === 5) { include __DIR__ . '/installer/templates/steps/step-5.phtml'; } ?>
</div>
</div>
</main>
@@ -148,6 +158,56 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time()
</div>
</footer>
<?php if ($installMode === 'mock') { ?>
<dialog class="installer-mock-dialog" data-mock-dialog aria-labelledby="mock-dialog-title" aria-describedby="mock-dialog-description">
<section class="installer-mock-panel">
<header class="installer-mock-header">
<div class="installer-mock-titlebar">
<p id="mock-dialog-title" class="typography-title-s text-neutral-primary">Mock settings</p>
<button type="button" class="mock-dialog-close" data-mock-close aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M5.293 5.293a1 1 0 0 1 1.414 0L10 8.586l3.293-3.293a1 1 0 1 1 1.414 1.414L11.414 10l3.293 3.293a1 1 0 0 1-1.414 1.414L10 11.414l-3.293 3.293a1 1 0 0 1-1.414-1.414L8.586 10 5.293 6.707a1 1 0 0 1 0-1.414Z"/>
</svg>
</button>
</div>
<p id="mock-dialog-description" class="typography-text-m-400 text-neutral-secondary">Adjust mock responses for this session.</p>
</header>
<div class="installer-mock-content">
<div class="mock-control">
<div class="mock-control-text">
<p class="typography-text-m-500 text-neutral-primary">Simulate session expiry</p>
<p class="typography-text-xs-400 text-neutral-secondary">Show the toast error before install starts.</p>
</div>
<label class="mock-switch">
<input type="checkbox" data-mock-toggle="toast" aria-label="Simulate session expiry">
<span class="mock-switch-track" aria-hidden="true"></span>
</label>
</div>
<div class="mock-control">
<div class="mock-control-text">
<p class="typography-text-m-500 text-neutral-primary">Simulate install error</p>
<p class="typography-text-xs-400 text-neutral-secondary">Show a failed step and retry flow.</p>
</div>
<label class="mock-switch">
<input type="checkbox" data-mock-toggle="error" aria-label="Simulate install error">
<span class="mock-switch-track" aria-hidden="true"></span>
</label>
</div>
<div class="mock-control">
<div class="mock-control-text">
<p class="typography-text-m-500 text-neutral-primary">Simulate account error</p>
<p class="typography-text-xs-400 text-neutral-secondary">Fail the account creation step.</p>
</div>
<label class="mock-switch">
<input type="checkbox" data-mock-toggle="accountError" aria-label="Simulate account error">
<span class="mock-switch-track" aria-hidden="true"></span>
</label>
</div>
</div>
</section>
</dialog>
<?php } ?>
<template id="field-error-template">
<div class="field-error typography-text-xs-400 text-error">
<span class="field-error-text"></span>
+274 -6
View File
@@ -72,6 +72,7 @@
/* Overlay colors */
--overlay-neutral-hover: rgba(25, 25, 28, 0.03);
--overlay-neutral-pressed: rgba(25, 25, 28, 0.04);
--overlay-scrim: rgba(25, 25, 28, 0.8);
/* Icon sizes */
--icon-size-s: var(--base-16);
@@ -181,6 +182,235 @@
pointer-events: none;
}
.installer-mock-dialog {
width: 100%;
padding: 0;
border: none;
background: none;
overflow: visible;
}
.installer-mock-dialog:focus {
outline: none;
}
.installer-mock-dialog::backdrop {
background: var(--overlay-scrim);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-in-out);
}
.installer-mock-panel {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: hidden;
margin-inline: auto;
inline-size: 600px;
max-inline-size: 90vw;
border-radius: var(--border-radius-l);
border: var(--border-width-s) solid var(--border-neutral);
background: var(--bgcolor-neutral-primary);
color: var(--fgcolor-neutral-secondary);
box-shadow:
0px 56px 32px 0px rgba(0, 0, 0, 0.02),
0px 6px 14px 0px rgba(0, 0, 0, 0.04),
0px 24px 25px 0px rgba(0, 0, 0, 0.03);
transform: scale(0.96);
opacity: 0;
transition:
transform var(--duration-medium) var(--ease-in-out),
opacity var(--duration-medium) var(--ease-in-out);
}
.installer-mock-header {
display: flex;
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: var(--gap-xxxs);
padding: var(--space-8);
padding-block-start: var(--space-7);
border-bottom: var(--border-width-s) solid var(--border-neutral);
background: var(--bgcolor-neutral-primary);
}
.installer-mock-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap-xl);
width: 100%;
}
.mock-dialog-close {
width: 32px;
height: 32px;
border: var(--border-width-s) solid transparent;
background: transparent;
color: var(--fgcolor-neutral-tertiary);
border-radius: var(--border-radius-s);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color var(--duration-fast) var(--ease-in-out), background var(--duration-fast) var(--ease-in-out);
}
.mock-dialog-close svg {
width: 20px;
height: 20px;
}
.mock-dialog-close:hover {
color: var(--fgcolor-neutral-secondary);
background: var(--overlay-neutral-hover);
}
.mock-dialog-close:active {
color: var(--fgcolor-neutral-secondary);
background: var(--overlay-neutral-pressed);
}
.mock-dialog-close:focus-visible {
outline: var(--border-width-l) solid var(--border-focus);
outline-offset: var(--border-width-s);
}
.installer-mock-content {
width: 100%;
padding: var(--space-8);
max-height: 70vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.mock-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.mock-control-text {
display: flex;
flex-direction: column;
gap: var(--gap-none);
}
.mock-control-text p {
margin: 0;
}
.mock-control.is-disabled {
opacity: 0.5;
}
.mock-control.is-disabled .mock-switch {
cursor: not-allowed;
}
.mock-switch {
position: relative;
width: 36px;
height: 20px;
flex-shrink: 0;
cursor: pointer;
}
.mock-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.mock-switch-track {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--bgcolor-neutral-secondary);
border: 1px solid var(--border-neutral-strong);
transition: background var(--duration-medium) var(--ease-standard), border-color var(--duration-medium) var(--ease-standard);
}
.mock-switch-track::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
top: 1px;
left: 1px;
border-radius: 999px;
background: var(--bgcolor-neutral-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
transition: transform var(--duration-medium) var(--ease-standard);
}
.mock-switch input:checked + .mock-switch-track {
background: var(--bgcolor-accent);
border-color: var(--bgcolor-accent);
}
.mock-switch input:checked + .mock-switch-track::after {
transform: translateX(16px);
}
.mock-switch input:focus-visible + .mock-switch-track {
box-shadow: 0 0 0 2px rgba(253, 54, 110, 0.3);
}
.mock-switch input:disabled + .mock-switch-track {
background: var(--bgcolor-neutral-tertiary);
border-color: var(--border-neutral);
cursor: not-allowed;
}
@media (max-width: 640px) {
.installer-mock-panel {
inline-size: 100%;
max-inline-size: calc(100% - 24px);
}
.installer-mock-header {
padding: var(--space-6);
}
.installer-mock-content {
padding: var(--space-6);
}
.mock-control {
align-items: flex-start;
}
}
.installer-mock-dialog[open]::backdrop {
opacity: 1;
animation: mock-backdrop-enter var(--duration-fast) var(--ease-in-out) forwards;
}
.installer-mock-dialog[open] .installer-mock-panel {
opacity: 1;
animation: mock-modal-show var(--duration-medium) var(--ease-in-out) forwards;
}
@keyframes mock-modal-show {
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes mock-backdrop-enter {
to {
opacity: 1;
}
}
.installer-toast {
inline-size: 24rem;
display: inline-flex;
@@ -284,6 +514,10 @@
outline-offset: var(--border-width-s);
}
.is-hidden {
display: none !important;
}
@media (min-width: 768px) {
.installer-toast-stack {
top: var(--base-24);
@@ -348,7 +582,7 @@ body {
z-index: 0;
}
.installer-page[data-step='4'] .installer-backdrop {
.installer-page[data-step='5'] .installer-backdrop {
opacity: 0.4;
}
@@ -391,7 +625,7 @@ body {
overflow: hidden;
}
.installer-page[data-step='4'] .installer-card {
.installer-page[data-step='5'] .installer-card {
opacity: 0;
pointer-events: none;
}
@@ -439,7 +673,7 @@ body {
z-index: 1;
}
.installer-page[data-step='4'] .install-screen {
.installer-page[data-step='5'] .install-screen {
opacity: 1;
pointer-events: auto;
}
@@ -834,6 +1068,8 @@ body {
border-radius: 0 0 var(--border-radius-m) var(--border-radius-m);
display: flex;
justify-content: flex-end;
flex-direction: row;
gap: var(--gap-m);
}
.install-error-details .button {
@@ -1346,10 +1582,17 @@ body {
gap: var(--gap-m);
width: 100%;
padding: var(--space-6);
background: var(--bgcolor-warning-weaker);
background: var(--bgcolor-neutral-default);
border-radius: var(--border-radius-s);
outline: var(--border-width-s) solid var(--border-warning-weak);
outline: var(--border-width-s) solid var(--border-neutral-strong);
outline-offset: -1px;
--alert-primary-color: var(--fgcolor-neutral-secondary);
}
.inline-alert--warning {
background: var(--bgcolor-warning-weaker);
outline-color: var(--border-warning-weak);
--alert-primary-color: var(--fgcolor-warning);
}
.inline-alert-content {
@@ -1365,7 +1608,7 @@ body {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fgcolor-warning);
color: var(--alert-primary-color);
flex-shrink: 0;
}
@@ -1488,6 +1731,7 @@ body {
background: transparent;
color: var(--fgcolor-neutral-secondary);
cursor: pointer;
border-radius: var(--border-radius-xs);
}
.input-icon-button svg {
@@ -1496,6 +1740,30 @@ body {
display: block;
}
.input-icon-button:hover {
background: var(--overlay-neutral-hover);
}
.input-icon-button:active {
background: var(--overlay-neutral-pressed);
}
.password-toggle-icon {
display: inline-flex;
}
.password-toggle-icon.is-hidden {
display: none;
}
.password-toggle.is-visible [data-password-icon="show"] {
display: none;
}
.password-toggle.is-visible [data-password-icon="hide"] {
display: inline-flex;
}
.icon-button {
display: inline-flex;
align-items: center;
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" fill-rule="evenodd" d="M3.707 2.293a1 1 0 1 0-1.414 1.414l14 14a1 1 0 1 0 1.414-1.414l-1.473-1.473A10 10 0 0 0 19.543 10C18.268 5.943 14.478 3 10 3a9.96 9.96 0 0 0-4.512 1.074zm4.261 4.26 1.514 1.515a2.003 2.003 0 0 1 2.45 2.45l1.515 1.514a4 4 0 0 0-5.478-5.478" clip-rule="evenodd"/>
<path fill="currentColor" d="M12.454 16.697 9.75 13.992a4 4 0 0 1-3.742-3.742L2.335 6.578A10 10 0 0 0 .458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.67-.105 2.454-.303"/>
</svg>

After

Width:  |  Height:  |  Size: 568 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" d="M10 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/>
<path fill="currentColor" fill-rule="evenodd" d="M.457 10C1.732 5.943 5.522 3 10 3s8.267 2.943 9.542 7c-1.275 4.057-5.065 7-9.542 7-4.478 0-8.268-2.943-9.543-7M14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

+18 -29
View File
@@ -9,13 +9,11 @@
if (!stepContainer || !installerCard) return;
const { isMockToastMode } = window.InstallerStepsContext || {};
const { validateInstallRequest } = window.InstallerStepsProgress || {};
const { showToast } = window.InstallerToast || {};
const isUpgrade = document.body?.dataset.upgrade === 'true';
const stepFlow = isUpgrade ? [1, 3, 4] : [1, 2, 3, 4];
const cardSteps = stepFlow.filter((step) => step !== 4);
const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5];
const cardSteps = stepFlow.filter((step) => step !== 5);
const normalizeStep = (step) => {
const numeric = clampStep(step);
@@ -32,13 +30,13 @@
const buildStepConfig = () => {
const config = {};
stepFlow.forEach((step, index) => {
if (step === 4) {
if (step === 5) {
config[step] = { back: { target: null }, next: { target: null } };
return;
}
const prev = stepFlow[index - 1] ?? null;
const next = stepFlow[index + 1] ?? null;
const label = next === 4 ? (isUpgrade ? 'Update' : 'Install') : 'Next';
const label = next === 5 ? (isUpgrade ? 'Update' : 'Install') : 'Next';
config[step] = {
back: { target: prev },
next: { label, target: next }
@@ -55,7 +53,7 @@
let pendingStep = null;
let pendingPushState = false;
const clampStep = (step) => Math.max(1, Math.min(4, step));
const clampStep = (step) => Math.max(1, Math.min(5, step));
const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.());
const scrollToFirstError = (panel) => {
@@ -331,7 +329,7 @@
fetchStepHtml(targetStep, url)
.then((html) => {
if (targetStep === 4) {
if (targetStep === 5) {
showInstallScreen(targetStep, html);
isTransitioning = false;
if (pendingStep !== null && pendingStep !== targetStep) {
@@ -370,8 +368,8 @@
const requestStep = (step, pushState) => {
const targetStep = normalizeStep(Number(step));
if (isInstallLocked() && targetStep !== 4) {
loadStep(4, true);
if (isInstallLocked() && targetStep !== 5) {
loadStep(5, true);
return;
}
if (isTransitioning) {
@@ -401,23 +399,14 @@
}
}
}
if (action === 'next' && String(target) === '4' && typeof isMockToastMode === 'function' && isMockToastMode()) {
showToast?.({
status: 'error',
title: 'Session expired',
description: 'Refresh the page and try again.',
dismissible: true
});
return;
}
if (action === 'next' && String(target) === '4' && typeof validateInstallRequest === 'function') {
if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') {
const isValid = await validateInstallRequest();
if (!isValid) {
return;
}
}
if (isInstallLocked() && Number(target) !== 4) {
requestStep(4, true);
if (isInstallLocked() && Number(target) !== 5) {
requestStep(5, true);
return;
}
requestStep(target, true);
@@ -425,8 +414,8 @@
window.addEventListener('popstate', (event) => {
const step = event.state?.step || getStepFromUrl();
if (isInstallLocked() && Number(step) !== 4) {
requestStep(4, false);
if (isInstallLocked() && Number(step) !== 5) {
requestStep(5, false);
return;
}
requestStep(step, false);
@@ -434,10 +423,10 @@
document.addEventListener('DOMContentLoaded', () => {
let step = getStepFromUrl();
if (isInstallLocked() && step !== 4) {
const url = buildStepUrl(4);
window.history.replaceState({ step: 4 }, '', url.toString());
step = 4;
if (isInstallLocked() && step !== 5) {
const url = buildStepUrl(5);
window.history.replaceState({ step: 5 }, '', url.toString());
step = 5;
} else {
const url = buildStepUrl(step);
window.history.replaceState({ step }, '', url.toString());
@@ -445,7 +434,7 @@
const activePanel = stepContainer.querySelector('.step-panel') || stepContainer;
runStepInit(step, activePanel);
measureStepHeight(activePanel);
if (step === 4 && installScreen) {
if (step === 5 && installScreen) {
runStepInit(step, installScreen);
}
const preload = () => {
@@ -2,27 +2,13 @@
const getBodyDataset = () => document.body?.dataset ?? {};
const isUpgradeMode = () => getBodyDataset().upgrade === 'true';
const getLockedDatabase = () => getBodyDataset().lockedDatabase || '';
const isMockMode = () => document.body?.dataset.installMode === 'mock'
|| (typeof navigator !== 'undefined' && navigator.webdriver);
const isMockErrorMode = () => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
return params.get('mock-error') === '1';
};
const getMockToastParam = () => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
return params.get('mock-toast') === '1';
};
const MOCK_TOAST_ENABLED = getMockToastParam();
const isMockToastMode = () => MOCK_TOAST_ENABLED;
const isMockProgressMode = () => isMockMode() || isMockErrorMode();
const STEP_IDS = Object.freeze({
CONFIG_FILES: 'config-files',
DOCKER_COMPOSE: 'docker-compose',
ENV_VARS: 'env-vars',
DOCKER_CONTAINERS: 'docker-containers'
DOCKER_CONTAINERS: 'docker-containers',
ACCOUNT_SETUP: 'account-setup'
});
const STATUS = Object.freeze({
@@ -80,6 +66,11 @@
id: STEP_IDS.DOCKER_CONTAINERS,
inProgress: 'Starting Docker containers...',
done: 'Docker containers started'
},
{
id: STEP_IDS.ACCOUNT_SETUP,
inProgress: 'Creating Appwrite account...',
done: 'Appwrite account created (redirecting...)'
}
]);
@@ -98,17 +89,13 @@
const clampStep = (step) => {
const numeric = Number(step);
if (Number.isNaN(numeric)) return 1;
return Math.max(1, Math.min(4, numeric));
return Math.max(1, Math.min(5, numeric));
};
window.InstallerStepsContext = Object.freeze({
getBodyDataset,
isUpgradeMode,
getLockedDatabase,
isMockMode,
isMockErrorMode,
isMockToastMode,
isMockProgressMode,
STEP_IDS,
STATUS,
SSE_EVENTS,
@@ -0,0 +1,97 @@
(() => {
const { isMockMode, MOCK_SETTINGS_KEY, readMockSettings } = window.InstallerMock || {};
if (!isMockMode?.()) return;
const dialog = document.querySelector('[data-mock-dialog]');
if (!dialog) return;
const footerLogo = document.querySelector('.installer-footer .appwrite-logo');
const closeButtons = dialog.querySelectorAll('[data-mock-close]');
const toggleInputs = Array.from(dialog.querySelectorAll('[data-mock-toggle]'));
const readSettings = () => readMockSettings?.() ?? {};
const writeSettings = (settings) => {
try {
sessionStorage.setItem(MOCK_SETTINGS_KEY, JSON.stringify(settings));
} catch (error) {}
};
const normalizeSettings = (settings) => ({
error: Boolean(settings?.error),
toast: Boolean(settings?.toast),
accountError: Boolean(settings?.accountError)
});
const applySettingsToUI = (settings) => {
const normalized = normalizeSettings(settings);
toggleInputs.forEach((input) => {
const key = input.dataset.mockToggle;
if (!key) return;
input.checked = Boolean(normalized[key]);
if (key === 'accountError') {
input.disabled = normalized.error;
input.closest('.mock-control')?.classList.toggle('is-disabled', normalized.error);
}
if (key === 'error') {
input.disabled = normalized.accountError;
input.closest('.mock-control')?.classList.toggle('is-disabled', normalized.accountError);
}
});
};
const updateSetting = (key, value) => {
const current = normalizeSettings(readSettings());
const next = { ...current, [key]: value };
// Make error and accountError mutually exclusive
if (key === 'error' && value) {
next.accountError = false;
}
if (key === 'accountError' && value) {
next.error = false;
}
writeSettings(next);
applySettingsToUI(next);
};
const openDialog = () => {
applySettingsToUI(readSettings());
if (typeof dialog.showModal === 'function') {
dialog.showModal();
} else {
dialog.setAttribute('open', '');
}
};
const closeDialog = () => {
if (dialog.open) {
dialog.close();
} else {
dialog.removeAttribute('open');
}
};
footerLogo?.addEventListener('dblclick', openDialog);
closeButtons.forEach((button) => button.addEventListener('click', closeDialog));
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
closeDialog();
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
closeDialog();
}
});
toggleInputs.forEach((input) => {
input.addEventListener('change', (event) => {
const key = event.target?.dataset?.mockToggle;
if (!key) return;
updateSetting(key, event.target.checked);
});
});
applySettingsToUI(readSettings());
})();
@@ -0,0 +1,40 @@
(() => {
const getBodyDataset = () => document.body?.dataset ?? {};
const isMockMode = () => getBodyDataset().installMode === 'mock'
|| (typeof navigator !== 'undefined' && navigator.webdriver);
const MOCK_SETTINGS_KEY = 'appwrite-installer-mock-settings';
const readMockSettings = () => {
if (typeof window === 'undefined') return {};
try {
const raw = sessionStorage.getItem(MOCK_SETTINGS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
return parsed;
} catch (error) {
return {};
}
};
const getMockFlag = (key) => {
const settings = readMockSettings();
return Boolean(settings?.[key]);
};
const isMockErrorMode = () => isMockMode() && getMockFlag('error');
const isMockToastMode = () => isMockMode() && getMockFlag('toast');
const isMockAccountErrorMode = () => isMockMode() && getMockFlag('accountError');
const isMockProgressMode = () => isMockMode() || isMockErrorMode();
window.InstallerMock = Object.freeze({
isMockMode,
isMockErrorMode,
isMockAccountErrorMode,
isMockToastMode,
isMockProgressMode,
MOCK_SETTINGS_KEY,
readMockSettings
});
})();
@@ -2,15 +2,18 @@
const {
INSTALLATION_STEPS,
TIMINGS,
isMockMode,
isMockErrorMode,
isMockProgressMode,
isMockToastMode,
getBodyDataset,
STEP_IDS,
STATUS,
SSE_EVENTS
} = window.InstallerStepsContext;
const {
isMockMode,
isMockErrorMode,
isMockAccountErrorMode,
isMockProgressMode,
isMockToastMode
} = window.InstallerMock || {};
const {
formState,
applyLockPayload,
@@ -49,11 +52,11 @@
};
const validateInstallRequest = async () => {
if (isMockProgressMode?.()) return true;
if (isMockToastMode?.()) {
showCsrfToast();
return false;
}
if (isMockProgressMode?.()) return true;
try {
const response = await fetch('/install/validate', {
method: 'POST',
@@ -136,6 +139,13 @@
if (text) {
text.textContent = label;
}
// Show/hide "Navigate to Console" button for account setup errors
const consoleBtn = row.querySelector('[data-install-console]');
if (consoleBtn) {
const shouldShow = step.id === STEP_IDS.ACCOUNT_SETUP && status === STATUS.ERROR;
consoleBtn.classList.toggle('is-hidden', !shouldShow);
}
};
const normalizeInstallError = (message) => {
@@ -255,15 +265,28 @@
window.location.href = url;
};
const notifyInstallComplete = (installId) => {
const notifyInstallComplete = (installId, session) => {
if (isMockProgressMode?.()) return Promise.resolve();
if (!installId) return Promise.resolve();
const payload = { installId };
const sessionSecret = session?.sessionSecret || session?.secret;
const sessionId = session?.sessionId || session?.id;
const sessionExpire = session?.sessionExpire || session?.expire;
if (sessionSecret) {
payload.sessionSecret = sessionSecret;
}
if (sessionId) {
payload.sessionId = sessionId;
}
if (sessionExpire) {
payload.sessionExpire = sessionExpire;
}
return fetch('/install/complete', {
method: 'POST',
headers: withCsrfHeader({
'Content-Type': 'application/json'
}),
body: JSON.stringify({ installId })
body: JSON.stringify(payload)
}).catch(() => {});
};
@@ -277,6 +300,9 @@
const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443';
const normalizedEmail = (formState?.emailCertificates || '').trim();
const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim();
const normalizedAccountName = (formState?.accountName || '').trim();
const normalizedAccountEmail = (formState?.accountEmail || '').trim();
const normalizedAccountPassword = (formState?.accountPassword || '').trim();
return {
installId,
@@ -286,7 +312,10 @@
appDomain: normalizedDomain,
emailCertificates: normalizedEmail,
opensslKey: (formState?.opensslKey || '').trim(),
assistantOpenAIKey: normalizedAssistantKey
assistantOpenAIKey: normalizedAssistantKey,
accountName: normalizedAccountName,
accountEmail: normalizedAccountEmail,
accountPassword: normalizedAccountPassword
};
};
@@ -358,7 +387,7 @@
}
};
const initStep4 = (root) => {
const initStep5 = (root) => {
if (!root) return;
if (isMockProgressMode?.()) {
clearInstallLock?.();
@@ -697,7 +726,7 @@
const lastStep = INSTALLATION_STEPS[INSTALLATION_STEPS.length - 1];
if (lastStep) {
const lastState = progressState.get(lastStep.id);
if (!lastState || lastState.status !== STATUS.COMPLETED) {
if (!lastState || (lastState.status !== STATUS.COMPLETED && lastState.status !== STATUS.ERROR)) {
progressState.set(lastStep.id, {
status: STATUS.COMPLETED,
message: lastStep.done,
@@ -706,9 +735,26 @@
renderProgress();
}
}
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
const accountRequired = INSTALLATION_STEPS.some((step) => step.id === STEP_IDS.ACCOUNT_SETUP);
if (accountRequired && accountState?.status === STATUS.ERROR) {
finalizeInstall();
return;
}
const sessionDetails = accountState?.details;
if (accountRequired && !(sessionDetails?.sessionSecret || sessionDetails?.secret)) {
handleProgress({
step: STEP_IDS.ACCOUNT_SETUP,
status: STATUS.ERROR,
message: 'Account creation did not complete. Please retry.'
});
finalizeInstall();
return;
}
finalizeInstall();
notifyInstallComplete(activeInstall?.installId);
setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
});
return;
}
if (event === SSE_EVENTS.ERROR) {
@@ -808,24 +854,36 @@
};
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-install-retry]');
if (!button) return;
const row = button.closest('.install-row');
const stepId = row?.dataset.step;
retryInstallStep(stepId);
const consoleButton = event.target.closest('[data-install-console]');
const retryButton = event.target.closest('[data-install-retry]');
if (consoleButton) {
redirectToApp();
return;
}
if (retryButton) {
const row = retryButton.closest('.install-row');
const stepId = row?.dataset.step;
retryInstallStep(stepId);
}
});
const simulateInstallProgress = (options = {}) => {
if (activeInstall?.pollTimer) {
clearInterval(activeInstall.pollTimer);
}
const startIndex = options.retryStep
let index = options.retryStep
? Math.max(0, INSTALLATION_STEPS.findIndex((step) => step.id === options.retryStep))
: 0;
let index = startIndex;
const errorStepId = isMockErrorMode?.() && !options.retryStep
? STEP_IDS.DOCKER_CONTAINERS
: null;
let errorStepId = null;
if (isMockErrorMode?.() && !options.retryStep) {
errorStepId = isMockAccountErrorMode?.()
? STEP_IDS.ACCOUNT_SETUP
: STEP_IDS.DOCKER_CONTAINERS;
}
const mockErrorDetails = (window.InstallerConstants || {}).mockErrorDetails || {
output: 'Error response from daemon: manifest for appwrite/appwrite:local not found: manifest unknown',
trace: '#0 /usr/src/code/src/Appwrite/Platform/Tasks/Install.php(540): Appwrite\\\\Platform\\\\Tasks\\\\Install->performInstallation(...)\\n#1 {main}'
@@ -858,10 +916,13 @@
if (errorStepId && step.id === errorStepId) {
index += 1;
activeInstall.pollTimer = setTimeout(() => {
const errorMessage = errorStepId === STEP_IDS.ACCOUNT_SETUP
? 'A user with the same email already exists'
: 'Failed to start containers';
handleProgress({
step: step.id,
status: STATUS.ERROR,
message: 'Failed to start containers',
message: errorMessage,
details: mockErrorDetails
});
finalizeInstall();
@@ -902,7 +963,7 @@
};
window.InstallerStepsProgress = {
initStep4,
initStep5,
cleanupInstallFlow,
validateInstallRequest
};
@@ -2,9 +2,9 @@
const {
getBodyDataset,
isUpgradeMode,
getLockedDatabase,
isMockProgressMode
getLockedDatabase
} = window.InstallerStepsContext || {};
const { isMockProgressMode } = window.InstallerMock || {};
const INSTALL_LOCK_KEY = 'appwrite-install-lock';
const INSTALL_ID_KEY = 'appwrite-install-id';
@@ -16,7 +16,10 @@
httpsPort: null,
emailCertificates: null,
opensslKey: null,
assistantOpenAIKey: null
assistantOpenAIKey: null,
accountName: null,
accountEmail: null,
accountPassword: null
};
const dispatchStateChange = (key) => {
@@ -112,6 +115,9 @@
setStateIfEmpty('emailCertificates', payload.emailCertificates);
setStateIfEmpty('opensslKey', payload.opensslKey);
setStateIfEmpty('assistantOpenAIKey', payload.assistantOpenAIKey);
setStateIfEmpty('accountName', payload.accountName);
setStateIfEmpty('accountEmail', payload.accountEmail);
setStateIfEmpty('accountPassword', payload.accountPassword);
};
const getStoredInstallId = () => {
@@ -11,6 +11,11 @@
return numeric >= 1 && numeric <= 65535;
};
const isValidPassword = (value) => {
if (!value) return false;
return value.trim().length >= 8;
};
const isValidIPv4 = (host) => {
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false;
return host.split('.').every((part) => {
@@ -104,6 +109,7 @@
window.InstallerStepsValidation = {
isValidEmail,
isValidPort,
isValidPassword,
isValidHostnameInput,
extractHostname,
isLocalHost
+96 -7
View File
@@ -8,9 +8,9 @@
const {
INSTALLATION_STEPS,
clampStep,
isMockMode
clampStep
} = Context;
const { isMockMode } = window.InstallerMock || {};
const {
formState,
@@ -28,7 +28,8 @@
const {
isValidEmail,
isValidPort,
isValidHostnameInput
isValidHostnameInput,
isValidPassword
} = Validation;
const {
@@ -260,7 +261,62 @@
}
};
const hydrateStep3State = (root) => {
State.setStateIfEmpty?.('accountName', root.querySelector('#account-name')?.value);
State.setStateIfEmpty?.('accountEmail', root.querySelector('#account-email')?.value);
State.setStateIfEmpty?.('accountPassword', root.querySelector('#account-password')?.value);
};
const applyStep3State = (root) => {
const name = root.querySelector('#account-name');
if (name && formState.accountName) name.value = formState.accountName;
const email = root.querySelector('#account-email');
if (email && formState.accountEmail) email.value = formState.accountEmail;
const password = root.querySelector('#account-password');
if (password && formState.accountPassword) password.value = formState.accountPassword;
};
const initStep3 = (root) => {
if (!root) return;
if (isMockMode?.()) {
clearInstallLock?.();
clearInstallId?.();
}
syncInstallLockFlag?.();
applyLockPayload?.();
applyBodyDefaults?.();
hydrateStep3State(root);
applyStep3State(root);
const name = root.querySelector('#account-name');
const email = root.querySelector('#account-email');
const password = root.querySelector('#account-password');
const passwordToggle = root.querySelector('[data-password-toggle="account-password"]');
bindInputToState(name, 'accountName');
bindInputToState(email, 'accountEmail');
bindInputToState(password, 'accountPassword');
bindErrorClear?.(name);
bindErrorClear?.(email);
bindErrorClear?.(password);
if (password && passwordToggle) {
passwordToggle.addEventListener('click', () => {
const isVisible = passwordToggle.classList.toggle('is-visible');
password.type = isVisible ? 'text' : 'password';
passwordToggle.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
});
}
if (isInstallLocked?.()) {
disableControls?.(root);
}
};
const initStep4 = (root) => {
if (!root) return;
if (isMockMode?.()) {
clearInstallLock?.();
@@ -285,24 +341,26 @@
const root = container.querySelector('.step-layout') || container;
const normalized = clampStep?.(step) ?? 1;
Tooltips?.cleanupTooltipPortals?.();
if (normalized !== 3 && reviewListener) {
if (normalized !== 4 && reviewListener) {
document.removeEventListener('installer:state-change', reviewListener);
reviewListener = null;
}
if (normalized !== 4) {
if (normalized !== 5) {
Progress.cleanupInstallFlow?.();
}
if (normalized === 1) initStep1(root);
if (normalized === 2) initStep2(root);
if (normalized === 3) initStep3(root);
if (normalized === 4) Progress.initStep4?.(root);
if (normalized === 4) initStep4(root);
if (normalized === 5) Progress.initStep5?.(root);
};
window.InstallerSteps = {
initStep1,
initStep2,
initStep3,
initStep4: Progress.initStep4,
initStep4,
initStep5: Progress.initStep5,
installationSteps: INSTALLATION_STEPS || [],
isInstallLocked,
getInstallLock,
@@ -368,6 +426,37 @@
}
}
if (normalized === 3) {
clearFieldErrors?.(root);
let valid = true;
const name = root?.querySelector('#account-name');
const email = root?.querySelector('#account-email');
const password = root?.querySelector('#account-password');
if (!name || !name.value.trim()) {
setFieldError?.(name, 'Please enter a name');
valid = false;
}
if (!email || !email.value.trim()) {
setFieldError?.(email, 'Please enter an email address');
valid = false;
} else if (!isValidEmail?.(email.value.trim())) {
setFieldError?.(email, 'Please enter a valid email address');
valid = false;
}
if (!password || !password.value.trim()) {
setFieldError?.(password, 'Please enter a password');
valid = false;
} else if (!isValidPassword?.(password.value.trim())) {
setFieldError?.(password, 'Password must be at least 8 characters');
valid = false;
}
return valid;
}
return true;
}
};
@@ -13,7 +13,7 @@ $secretKeyValue = htmlspecialchars((string) $defaultSecretKey, ENT_QUOTES, 'UTF-
</div>
<div class="stack-xl">
<div class="inline-alert">
<div class="inline-alert inline-alert--warning">
<div class="inline-alert-content">
<div class="inline-alert-icon" aria-hidden="true">
<?php include __DIR__ . '/../../icons/warning.svg'; ?>
@@ -1,72 +1,69 @@
<?php
$isUpgrade = $isUpgrade ?? false;
$lockedDatabase = $lockedDatabase ?? null;
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
$defaultHttpPort = $defaultHttpPort ?? '80';
$defaultHttpsPort = $defaultHttpsPort ?? '443';
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
$defaultSecretKey = $defaultSecretKey ?? '';
$defaultDatabase = $defaultDatabase ?? 'mongodb';
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
$defaultDatabaseLabel = $selectedDatabase === 'mariadb' ? 'MariaDB' : 'MongoDB';
$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing';
$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
$defaultAccountEmail = $defaultAccountEmail ?? '';
$defaultAccountName = $defaultAccountName ?? '';
$accountEmailValue = htmlspecialchars($defaultAccountEmail, ENT_QUOTES, 'UTF-8');
$accountNameValue = htmlspecialchars($defaultAccountName, ENT_QUOTES, 'UTF-8');
?>
<div class="step-layout" data-step="3">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Review your update' : 'Review your setup'; ?></h1>
<h1 class="typography-title-s text-neutral-primary">Create your account</h1>
<p class="typography-text-m-400 text-neutral-secondary">
<?php echo $isUpgrade ? 'Confirm your settings before updating Appwrite.' : 'Check your settings below before completing the installation.'; ?>
Set up the email and password for your Appwrite account. You can use these
credentials to sign in later.
</p>
</div>
<div class="stack-xl">
<div class="review-card">
<div class="stack-s">
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="appDomain">
<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Hostname</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="database">
<?php echo htmlspecialchars((string) $defaultDatabaseLabel, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Database</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpPort">
<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTP port</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpsPort">
<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTPS port</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="emailCertificates">
<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">SSL certificate email</div>
</div>
<div class="review-row">
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
</div>
<div class="review-row">
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
<div class="stack-l">
<div class="input-group stack-xs">
<label for="account-name" class="label-text typography-text-m-500 text-neutral-secondary">Name</label>
<input
type="text"
id="account-name"
name="accountName"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="John Doe"
value="<?php echo $accountNameValue; ?>"
autocomplete="name"
>
</div>
<div class="input-group stack-xs">
<label for="account-email" class="label-text typography-text-m-500 text-neutral-secondary">Email</label>
<input
type="email"
id="account-email"
name="accountEmail"
class="input-field typography-text-m-400 text-neutral-primary"
placeholder="you@example.com"
value="<?php echo $accountEmailValue; ?>"
autocomplete="email"
>
</div>
<div class="input-group stack-xs">
<label for="account-password"
class="label-text typography-text-m-500 text-neutral-secondary">Password</label>
<div class="input-action">
<input
type="password"
id="account-password"
name="accountPassword"
class="input-action-input typography-text-m-400 text-neutral-primary"
placeholder="Password must be at least 8 characters long"
autocomplete="new-password"
>
<div class="input-action-buttons">
<button type="button" class="input-icon-button password-toggle" aria-label="Show password" data-password-toggle="account-password">
<span class="password-toggle-icon is-hidden" data-password-icon="hide">
<?php include __DIR__ . '/../../icons/eye-off.svg'; ?>
</span>
<span class="password-toggle-icon" data-password-icon="show">
<?php include __DIR__ . '/../../icons/eye.svg'; ?>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1,49 +1,72 @@
<?php
$isUpgrade = $isUpgrade ?? false;
$lockedDatabase = $lockedDatabase ?? null;
$defaultAppDomain = $defaultAppDomain ?? 'localhost';
$defaultHttpPort = $defaultHttpPort ?? '80';
$defaultHttpsPort = $defaultHttpsPort ?? '443';
$defaultEmailCertificates = $defaultEmailCertificates ?? '';
$defaultSecretKey = $defaultSecretKey ?? '';
$defaultDatabase = $defaultDatabase ?? 'mongodb';
$selectedDatabase = $lockedDatabase ?: $defaultDatabase;
$defaultDatabaseLabel = $selectedDatabase === 'mariadb' ? 'MariaDB' : 'MongoDB';
$badgeLabel = $defaultSecretKey !== '' ? 'Generated' : 'Missing';
$badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning';
?>
<div class="step-layout install-layout" data-step="4">
<div class="install-card">
<div class="install-panel">
<div class="install-header">
<div class="typography-text-m-400 text-neutral-primary">
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
</div>
</div>
<div class="install-list" data-install-list></div>
<div class="step-layout" data-step="4">
<div class="stack-xl">
<div class="stack-xxxs">
<h1 class="typography-title-s text-neutral-primary"><?php echo $isUpgrade ? 'Review your update' : 'Review your setup'; ?></h1>
<p class="typography-text-m-400 text-neutral-secondary">
<?php echo $isUpgrade ? 'Confirm your settings before updating Appwrite.' : 'Check your settings below before completing the installation.'; ?>
</p>
</div>
</div>
<template id="install-row-template">
<div class="install-row" data-status="in-progress">
<div class="install-row-main">
<div class="install-row-label">
<span class="install-icon" aria-hidden="true">
<span class="install-icon-spinner">
<?php include __DIR__ . '/../../icons/install-spinner.svg'; ?>
<div class="stack-xl">
<div class="review-card">
<div class="stack-s">
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="appDomain">
<?php echo htmlspecialchars((string) $defaultAppDomain, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Hostname</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="database">
<?php echo htmlspecialchars((string) $defaultDatabaseLabel, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Database</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpPort">
<?php echo htmlspecialchars((string) $defaultHttpPort, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTP port</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="httpsPort">
<?php echo htmlspecialchars((string) $defaultHttpsPort, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">HTTPS port</div>
</div>
<div class="review-row">
<div class="typography-text-m-500 text-neutral-primary" data-review-value="emailCertificates">
<?php echo htmlspecialchars((string) $defaultEmailCertificates, ENT_QUOTES, 'UTF-8'); ?>
</div>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">SSL certificate email</div>
</div>
<div class="review-row">
<span class="badge badge-neutral typography-text-xs-400" data-review-assistant-badge>Disabled</span>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Appwrite Assistant</div>
</div>
<div class="review-row">
<span class="badge <?php echo $badgeClass; ?> typography-text-xs-400" data-review-badge>
<?php echo htmlspecialchars((string) $badgeLabel, ENT_QUOTES, 'UTF-8'); ?>
</span>
<span class="install-icon-check">
<?php include __DIR__ . '/../../icons/install-check.svg'; ?>
</span>
<span class="install-icon-error">
<?php include __DIR__ . '/../../icons/exclamation-circle.svg'; ?>
</span>
</span>
<span class="install-text typography-text-m-400 text-neutral-primary" data-install-text></span>
</div>
<button type="button" class="install-row-toggle" aria-expanded="false" data-install-toggle>
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
</button>
</div>
<div class="install-row-details">
<div class="install-row-details-inner">
<pre class="install-error-code typography-text-xs-400" data-install-trace></pre>
<div class="install-error-actions">
<button type="button" class="button secondary" data-install-retry>
<span class="button-text typography-text-m-500">Retry</span>
</button>
<div class="review-label typography-text-xs-400 text-neutral-tertiary">Secret API key</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
@@ -0,0 +1,53 @@
<?php
$isUpgrade = $isUpgrade ?? false;
?>
<div class="step-layout install-layout" data-step="5">
<div class="install-card">
<div class="install-panel">
<div class="install-header">
<div class="typography-text-m-400 text-neutral-primary">
<?php echo $isUpgrade ? 'Updating your app…' : 'Installing your app…'; ?>
</div>
</div>
<div class="install-list" data-install-list></div>
</div>
</div>
<template id="install-row-template">
<div class="install-row" data-status="in-progress">
<div class="install-row-main">
<div class="install-row-label">
<span class="install-icon" aria-hidden="true">
<span class="install-icon-spinner">
<?php include __DIR__ . '/../../icons/install-spinner.svg'; ?>
</span>
<span class="install-icon-check">
<?php include __DIR__ . '/../../icons/install-check.svg'; ?>
</span>
<span class="install-icon-error">
<?php include __DIR__ . '/../../icons/exclamation-circle.svg'; ?>
</span>
</span>
<span class="install-text typography-text-m-400 text-neutral-primary" data-install-text></span>
</div>
<button type="button" class="install-row-toggle" aria-expanded="false" data-install-toggle>
<?php include __DIR__ . '/../../icons/chevron-down.svg'; ?>
</button>
</div>
<div class="install-row-details">
<div class="install-row-details-inner">
<pre class="install-error-code typography-text-xs-400" data-install-trace></pre>
<div class="install-error-actions">
<button type="button" class="button secondary" data-install-retry>
<span class="button-text typography-text-m-500">Retry</span>
</button>
<button type="button" class="button primary is-hidden" data-install-console>
<span class="button-text typography-text-m-500">Navigate to Console</span>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
@@ -172,11 +172,41 @@ class HttpHandler
$input = [];
}
$installId = $this->state->sanitizeInstallId($input['installId'] ?? '');
$sessionId = is_string($input['sessionId'] ?? null) ? $input['sessionId'] : null;
$sessionSecret = is_string($input['sessionSecret'] ?? null) ? $input['sessionSecret'] : null;
$sessionExpire = is_string($input['sessionExpire'] ?? null) ? $input['sessionExpire'] : null;
if ($installId !== '') {
$this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
}
if ($sessionSecret) {
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$sameSite = $isHttps ? 'None' : 'Lax';
$expires = 0;
if ($sessionExpire) {
$timestamp = strtotime($sessionExpire);
if ($timestamp !== false) {
$expires = $timestamp;
}
}
$cookieOptions = [
'expires' => $expires,
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => $sameSite,
];
setcookie('a_session_console', $sessionSecret, $cookieOptions);
setcookie('a_session_console_legacy', $sessionSecret, $cookieOptions);
if ($sessionId) {
header('X-Appwrite-Session: ' . $sessionId);
}
}
@unlink(Server::INSTALLER_CONFIG_FILE);
echo json_encode(['success' => true]);
if (function_exists('fastcgi_finish_request')) {
@@ -405,13 +435,19 @@ class HttpHandler
}
$vars = $this->config->getVars();
$envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars);
$shouldGenerateSecrets = !$installer->hasExistingConfig() && !$this->config->isUpgrade();
$envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars, $shouldGenerateSecrets);
$account = [
'name' => $input['accountName'] ?? '',
'email' => $input['accountEmail'] ?? '',
'password' => $input['accountPassword'] ?? '',
];
$this->state->writeProgressFile($installId, [
'payload' => [
'httpPort' => $input['httpPort'] ?? $this->config->getDefaultHttpPort(),
'httpsPort' => $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(),
'database' => $input['database'] ?? 'mongodb',
'database' => $lockedDatabase ?? ($input['database'] ?? 'mongodb'),
'appDomain' => $input['appDomain'] ?? 'localhost',
'emailCertificates' => $input['emailCertificates'] ?? '',
'opensslKeyHash' => $this->state->hashSensitiveValue((string) ($input['opensslKey'] ?? '')),
@@ -450,7 +486,8 @@ class HttpHandler
$this->config->getNoStart(),
$progress,
$retryStep,
$this->config->isUpgrade()
$this->config->isUpgrade(),
$account
);
if ($wantsStream) {
@@ -487,6 +524,7 @@ class HttpHandler
if ($installId !== '') {
$this->state->updateGlobalLock($installId, Server::STATUS_ERROR);
}
@unlink(Server::INSTALLER_CONFIG_FILE);
if ($wantsStream) {
$details = ['trace' => $e->getTraceAsString()];
$previous = $e->getPrevious();
@@ -329,7 +329,7 @@ class State
private function globalLockPath(): string
{
return sys_get_temp_dir() . '/appwrite-install-lock.json';
return Server::INSTALLER_LOCK_FILE;
}
private function isGlobalLockActive(?array $lock): bool
+7 -2
View File
@@ -12,19 +12,23 @@ class Server
{
public const int INSTALLER_WEB_PORT = 20080;
public const int INSTALLER_WEB_PORT_INTERNAL = 20080;
// temp files for state and config management!
public const string INSTALLER_LOCK_FILE = '/tmp/appwrite-install-lock.json';
public const string INSTALLER_CONFIG_FILE = '/tmp/appwrite-installer-config.json';
public const string STEP_ENV_VARS = 'env-vars';
public const string STEP_CONFIG_FILES = 'config-files';
public const string STEP_DOCKER_COMPOSE = 'docker-compose';
public const string STEP_DOCKER_CONTAINERS = 'docker-containers';
public const string STEP_ACCOUNT_SETUP = 'account-setup';
public const string STATUS_IN_PROGRESS = 'in-progress';
public const string STATUS_COMPLETED = 'completed';
public const string STATUS_ERROR = 'error';
private const string DEFAULT_IMAGE = 'appwrite-dev';
private const string DEFAULT_CONTAINER = 'appwrite-installer';
public const string DEFAULT_CONTAINER = 'appwrite-installer';
private const string DEV_SERVER_START_PATTERN = '/PHP\s+\d+\.\d+\.\d+\s+Development Server .* started/';
private State $state;
@@ -218,7 +222,8 @@ class Server
}
$tempDir = sys_get_temp_dir();
@unlink($tempDir . '/appwrite-install-lock.json');
@unlink(self::INSTALLER_LOCK_FILE);
@unlink(self::INSTALLER_CONFIG_FILE);
foreach (glob($tempDir . '/appwrite-install-*.json') ?: [] as $file) {
@unlink($file);
}
+177 -21
View File
@@ -10,6 +10,7 @@ use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Fetch\Client;
use Utopia\Platform\Action;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
@@ -19,6 +20,8 @@ class Install extends Action
private const int INSTALL_STEP_DELAY_SECONDS = 2;
private const int WEB_SERVER_CHECK_ATTEMPTS = 10;
private const int WEB_SERVER_CHECK_DELAY_SECONDS = 1;
private const int HEALTH_CHECK_ATTEMPTS = 10;
private const int HEALTH_CHECK_DELAY_SECONDS = 3;
protected string $hostPath = '';
protected ?bool $isLocalInstall = null;
@@ -72,7 +75,8 @@ class Install extends Action
// Check for existing installation
$data = $this->readExistingCompose();
$existingInstallation = $data !== '';
$envFileExists = file_exists($this->path . '/' . $this->getEnvFileName());
$existingInstallation = $data !== '' || $envFileExists;
if ($existingInstallation) {
$time = \time();
@@ -236,7 +240,8 @@ class Install extends Action
}
}
$input = $this->prepareEnvironmentVariables($userInput, $vars);
$shouldGenerateSecrets = !$existingInstallation && !$isUpgrade;
$input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets);
$this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade);
}
@@ -298,7 +303,7 @@ class Install extends Action
}
}
public function prepareEnvironmentVariables(array $userInput, array $vars): array
public function prepareEnvironmentVariables(array $userInput, array $vars, bool $shouldGenerateSecrets = true): array
{
$input = [];
$password = new Password();
@@ -309,12 +314,22 @@ class Install extends Action
$default = $var['default'] ?? null;
$hasDefault = $default !== null && $default !== '';
if (!empty($var['filter']) && $var['filter'] === 'token') {
$input[$var['name']] = $hasDefault ? $default : $token->generate();
if ($hasDefault) {
$input[$var['name']] = $default;
} elseif ($shouldGenerateSecrets) {
$input[$var['name']] = $token->generate();
} else {
$input[$var['name']] = '';
}
} elseif (!empty($var['filter']) && $var['filter'] === 'password') {
/*;#+@:/?& broke DSNs locally */
$input[$var['name']] = $hasDefault
? $default
: $this->generatePasswordValue($var['name'], $password);
if ($hasDefault) {
$input[$var['name']] = $default;
} elseif ($shouldGenerateSecrets) {
$input[$var['name']] = $this->generatePasswordValue($var['name'], $password);
} else {
$input[$var['name']] = '';
}
} else {
$input[$var['name']] = $default;
}
@@ -349,6 +364,18 @@ class Install extends Action
return $input;
}
public function hasExistingConfig(): bool
{
$isLocalInstall = $this->isLocalInstall();
$this->applyLocalPaths($isLocalInstall, true);
if ($this->readExistingCompose() !== '') {
return true;
}
return file_exists($this->path . '/' . $this->getEnvFileName());
}
private function updateProgress(?callable $progress, string $step, string $status, array $messages = [], array $details = [], ?string $messageOverride = null): void
{
if (!$progress) {
@@ -398,7 +425,8 @@ class Install extends Action
bool $noStart,
?callable $progress = null,
?string $resumeFromStep = null,
bool $isUpgrade = false
bool $isUpgrade = false,
array $account = []
): void {
$isLocalInstall = $this->isLocalInstall();
$this->applyLocalPaths($isLocalInstall, false);
@@ -412,8 +440,8 @@ class Install extends Action
}
if ($isLocalInstall) {
$organization = 'appwrite';
$image = 'appwrite';
$organization = 'appwrite';
}
$templateForEnv = new View($this->buildFromProjectPath('/app/views/install/env.phtml'));
@@ -469,7 +497,7 @@ class Install extends Action
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_IN_PROGRESS, $messages);
if (!$useExistingConfig) {
$this->writeComposeFile($templateForCompose, $isLocalInstall);
$this->writeComposeFile($templateForCompose);
}
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_COMPLETED, $messages);
@@ -480,7 +508,7 @@ class Install extends Action
$this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_IN_PROGRESS, $messages);
if (!$useExistingConfig) {
$this->writeEnvFile($templateForEnv, $isLocalInstall);
$this->writeEnvFile($templateForEnv);
}
$this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_COMPLETED, $messages);
@@ -495,9 +523,15 @@ class Install extends Action
$currentStep = InstallerServer::STEP_DOCKER_CONTAINERS;
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages);
$this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI);
$this->connectInstallerToAppwriteNetwork();
$this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
if (!$isUpgrade) {
// Create console account on fresh install only
$this->createInitialAdminAccount($account, $progress);
}
if ($isCLI) {
Console::success('Appwrite installed successfully');
}
@@ -521,6 +555,135 @@ class Install extends Action
}
}
private function createInitialAdminAccount(array $account, ?callable $progress): void
{
$name = $account['name'] ?? 'Admin';
$email = $account['email'] ?? null;
$password = $account['password'] ?? null;
if (!$email || !$password) {
return;
}
try {
$this->updateProgress(
$progress,
InstallerServer::STEP_ACCOUNT_SETUP,
InstallerServer::STATUS_IN_PROGRESS,
messageOverride: 'Creating Appwrite account'
);
$this->waitForApiReady();
// Create account and session
$userId = $this->makeApiCall('/v1/account', [
'userId' => 'unique()',
'email' => $email,
'password' => $password,
'name' => $name
]);
$session = $this->makeApiCall('/v1/account/sessions/email', [
'email' => $email,
'password' => $password
], true);
$this->updateProgress(
$progress,
InstallerServer::STEP_ACCOUNT_SETUP,
InstallerServer::STATUS_COMPLETED,
details: [
'userId' => $userId,
'sessionId' => $session['id'],
'sessionSecret' => $session['secret'],
'sessionExpire' => $session['expire'] ?? null
],
messageOverride: 'Account created successfully'
);
} catch (\Throwable $e) {
$this->updateProgress(
$progress,
InstallerServer::STEP_ACCOUNT_SETUP,
InstallerServer::STATUS_ERROR,
messageOverride: 'Account creation failed: ' . $e->getMessage()
);
}
}
private function waitForApiReady(): void
{
$client = new Client();
$client->setTimeout(5);
$pingHealth = function () use ($client): bool {
try {
return $client->fetch("http://traefik:80/v1/health/version")->getStatusCode() === 200;
} catch (\Throwable $e) {
return false;
}
};
for ($i = 0; $i < self::HEALTH_CHECK_ATTEMPTS; $i++) {
if ($pingHealth()) {
return;
}
if ($i < self::HEALTH_CHECK_ATTEMPTS - 1) {
sleep(self::HEALTH_CHECK_DELAY_SECONDS);
}
}
throw new \Exception('Failed to connect with Appwrite in time');
}
/* easier to just connect and call traefik endpoint */
private function connectInstallerToAppwriteNetwork(): void
{
$network = escapeshellarg('appwrite');
$container = escapeshellarg(InstallerServer::DEFAULT_CONTAINER);
@exec("docker network connect $network $container 2>/dev/null");
}
private function makeApiCall(string $endpoint, array $body, bool $extractSession = false)
{
$client = new Client();
$client
->setTimeout(30)
->addHeader('Content-Type', 'application/json')
->addHeader('X-Appwrite-Project', 'console');
$url = "http://traefik:80$endpoint";
$response = $client->fetch($url, Client::METHOD_POST, $body);
if ($response->getStatusCode() !== 201) {
$error = $response->json();
$message = $error['message'] ?? 'Unknown error';
throw new \Exception($message);
}
$data = $response->json();
if (!isset($data['$id'])) {
throw new \Exception('API response missing ID field');
}
if ($extractSession) {
$headers = $response->getHeaders();
$setCookie = $headers['set-cookie'] ?? $headers['Set-Cookie'] ?? null;
if (!$setCookie || !preg_match('/a_session_console=([^;]+)/', $setCookie, $matches)) {
throw new \Exception('Session created but no cookie found');
}
return [
'id' => $data['$id'],
'secret' => $matches[1],
'expire' => $data['expire'] ?? null
];
}
return $data['$id'];
}
private function buildStepMessages(bool $isUpgrade): array
{
$isUpgradeLabel = $isUpgrade ? 'updated' : 'created';
@@ -551,10 +714,8 @@ class Install extends Action
];
}
private function writeComposeFile(View $template, bool $isLocalInstall): void
private function writeComposeFile(View $template): void
{
// Always use container path for file operations
// Use a separate file for web installer to avoid conflicts
$composeFileName = $this->getComposeFileName();
$targetPath = $this->path . '/' . $composeFileName;
$renderedContent = $template->render(false);
@@ -567,10 +728,8 @@ class Install extends Action
}
}
private function writeEnvFile(View $template, bool $isLocalInstall): void
private function writeEnvFile(View $template): void
{
// Always use container path for file operations
// Use a separate env file for installer to avoid conflicts
$envFileName = $this->getEnvFileName();
if (!\file_put_contents($this->path . '/' . $envFileName, $template->render(false))) {
throw new \Exception('Failed to save environment variables file');
@@ -596,7 +755,7 @@ class Install extends Action
continue;
}
if (!preg_match('/^[A-Z0-9_]+$/', $key)) {
throw new \Exception('Invalid environment variable name');
throw new \Exception("Invalid environment variable name: $key");
}
$env .= $key . '=' . \escapeshellarg((string) $value) . ' ';
}
@@ -606,8 +765,6 @@ class Install extends Action
Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\"");
}
// Docker compose runs inside container, so use container paths
// The compose file itself contains host paths for volume mounts
$composeFileName = $this->getComposeFileName();
$composeFile = $this->path . '/' . $composeFileName;
@@ -645,7 +802,6 @@ class Install extends Action
protected function isLocalInstall(): bool
{
$config = null;
if ($this->isLocalInstall === null) {
$config = $this->readInstallerConfig();
$this->isLocalInstall = !empty($config['isLocal']);