basic setup flow works

This commit is contained in:
2020-04-03 16:45:47 +02:00
parent c66232f926
commit 92894f982b
22 changed files with 839 additions and 152 deletions
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.Text;
using zero.Core.Extensions;
namespace zero.Core.Api
{
public class Alias
{
private const char HYPHEN = '-';
private const char PLUS = '+';
private const char AMPERSAND = '&';
/// <summary>
/// Converts a term to a safe alias (suitable for URLs)
/// </summary>
public static string Generate(string value)
{
if (String.IsNullOrWhiteSpace(value))
{
return String.Empty;
}
char previous = default;
StringBuilder output = new StringBuilder();
for (int i = 0; i < value.Length; i++)
{
// get character in lower case
char character = char.ToLower(value[i]);
char target;
// do not handle surrogates
if (char.IsSurrogate(character))
{
continue;
}
// special replacements accents + umlauts
if (character.TryReplaceAccent(out char[] replacement))
{
if (replacement.Length > 1)
{
output.Append(replacement);
output.Remove(output.Length - 1, 1);
}
target = replacement[replacement.Length - 1];
}
// append character a-z, 0-9
else if (character.IsAZor09())
{
target = character;
}
// + sign for + and &
else if (character == PLUS || character == AMPERSAND)
{
target = PLUS;
}
// add hyphen for all other characters
else
{
target = HYPHEN;
}
// add default characters
if (target != HYPHEN && target != PLUS)
{
output.Append(target);
}
// add hyphen if it isn't first and previous char is not + or -
else if (target == HYPHEN && previous != default && previous != PLUS && previous != HYPHEN)
{
output.Append(target);
}
// add plus. do remove hyphen it is the previous character
else if (target == PLUS)
{
if (previous == HYPHEN)
{
output.Remove(output.Length - 1, 1);
}
output.Append(target);
}
if (output.Length > 0)
{
previous = output[output.Length - 1];
}
}
if (output.Length > 0 && !output[output.Length - 1].IsAZor09())
{
output.Remove(output.Length - 1, 1);
}
if (output.Length == 0)
{
output.Append(HYPHEN);
}
return output.ToString();
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using FluentValidation.Results;
using Raven.Client.Documents;
using Raven.Client.Documents.Conventions;
using Raven.Client.Documents.Session;
using System;
using System.Threading.Tasks;
using zero.Core.Entities;
using zero.Core.Entities.Setup;
using zero.Core.Validation;
namespace zero.Core.Api
{
public class SetupApi : ISetupApi
{
public SetupApi()
{
//Raven =
}
public async Task<EntityChangeResult<SetupModel>> Install(SetupModel model)
{
ValidationResult validation = await new SetupModelValidator().ValidateAsync(model);
if (!validation.IsValid)
{
return EntityChangeResult<SetupModel>.Fail(validation);
}
// test read & write permissions on folders
// TODO
// create temporary instance of database
DocumentStore raven = new DocumentStore()
{
Urls = model.Database.Url.Split(','),
Database = model.Database.Name
};
raven.Conventions.FindCollectionName = type =>
{
return Constants.Database.CollectionPrefix + DocumentConventions.DefaultGetCollectionName(type);
};
raven.Initialize();
// create application
Application app = new Application()
{
CreatedDate = DateTimeOffset.Now,
IsActive = true,
Name = model.AppName,
Alias = Alias.Generate(model.AppName)
};
using (IAsyncDocumentSession session = raven.OpenAsyncSession())
{
await session.StoreAsync(app);
await session.SaveChangesAsync();
}
raven.Dispose();
return EntityChangeResult<SetupModel>.Success(model);
}
}
public interface ISetupApi
{
Task<EntityChangeResult<SetupModel>> Install(SetupModel model);
}
}
+2
View File
@@ -5,6 +5,8 @@
public static class Database
{
public const string SharedAppId = "shared";
public const string CollectionPrefix = "zero.";
}
public static class Sections
+10 -1
View File
@@ -4,7 +4,7 @@ using zero.Core.Attributes;
namespace zero.Core.Entities
{
[DebuggerDisplay("Id = {Id,nq}, Name = {Name}")]
[DebuggerDisplay("Id = {Id,nq}, Name = {Name}, Alias = {Alias}")]
public abstract class DatabaseEntity : IDatabaseEntity
{
/// <inheritdoc/>
@@ -18,6 +18,9 @@ namespace zero.Core.Entities
/// <inheritdoc/>
public string Name { get; set; }
/// <inheritdoc/>
public string Alias { get; set; }
/// <inheritdoc/>
public uint Sort { get; set; }
@@ -46,6 +49,12 @@ namespace zero.Core.Entities
/// </summary>
string Name { get; set; }
/// <summary>
/// Unique alias which can be used in the frontend and URLs
/// As generating aliases from the name would not be unique we append an incremental number if the desired alias is not available anymore
/// </summary>
string Alias { get; set; }
/// <summary>
/// Sort order
/// </summary>
+28
View File
@@ -0,0 +1,28 @@
namespace zero.Core.Entities.Setup
{
public sealed class SetupModel
{
public string AppName { get; set; }
public SetupUserModel User { get; set; }
public SetupDatabaseModel Database { get; set; }
}
public sealed class SetupUserModel
{
public string Name { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
public sealed class SetupDatabaseModel
{
public string Url { get; set; }
public string Name { get; set; }
}
}
-41
View File
@@ -1,41 +0,0 @@
namespace zero.Core.Entities.Setup
{
public sealed class SetupSave
{
public string AppName { get; set; }
public SetupUserSave User { get; set; }
public SetupDatabaseSave Database { get; set; }
public SetupSavePart Part { get; set; }
}
public sealed class SetupUserSave
{
public string Name { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
public sealed class SetupDatabaseSave
{
public string Url { get; set; }
public string Name { get; set; }
}
public enum SetupSavePart
{
ValidateUser = 0,
ValidateApplication = 1,
ValidateDatabase = 2,
ValidateFolderAccess = 3,
WriteSettings = 4,
SaveData = 5
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Generic;
namespace zero.Core.Extensions
{
public static class CharExtensions
{
private static Dictionary<char, char[]> accents { get; } = new Dictionary<char, char[]>()
{
{ 'ä', new char[2] { 'a', 'e' } },
{ 'á', new char[1] { 'a' } },
{ 'à', new char[1] { 'a' } },
{ 'ó', new char[1] { 'o' } },
{ 'ò', new char[1] { 'o' } },
{ 'é', new char[1] { 'e' } },
{ 'è', new char[1] { 'e' } },
{ 'ú', new char[1] { 'u' } },
{ 'ù', new char[1] { 'u' } },
{ 'í', new char[1] { 'i' } },
{ 'ì', new char[1] { 'i' } },
{ 'ö', new char[2] { 'o', 'e' } },
{ 'ü', new char[2] { 'u', 'e' } },
{ 'ß', new char[2] { 's', 's' } },
{ '&', new char[1] { '+' } }
};
/// <summary>
/// Check if a character is from a-z, A-Z or 0-9
/// </summary>
public static bool IsAZor09(this char value)
{
return (value >= 0x41 && value <= 0x5A) || (value >= 0x61 && value <= 0x7a) || (value >= 0x30 && value <= 0x39);
}
/// <summary>
/// Check if a character is in ASCII range
/// </summary>
public static bool IsASCII(this char value)
{
return value < 128;
}
/// <summary>
/// Replaces an accent or umlaut with the appropriate URL + file ready variant
/// </summary>
public static bool TryReplaceAccent(this char value, out char[] result)
{
if (!accents.ContainsKey(value))
{
result = null;
return false;
}
result = accents[value];
return true;
}
}
}
@@ -0,0 +1,22 @@
using FluentValidation;
using zero.Core.Entities.Setup;
using zero.Core.Extensions;
namespace zero.Core.Validation
{
public class SetupModelValidator : AbstractValidator<SetupModel>
{
public SetupModelValidator()
{
RuleFor(x => x.User).NotNull();
RuleFor(x => x.User.Email).NotEmpty().Email();
RuleFor(x => x.User.Name).MaximumLength(40).NotEmpty();
RuleFor(x => x.User.Password).MaximumLength(1024); // TODO password policy
RuleFor(x => x.AppName).MaximumLength(40).NotEmpty();
RuleFor(x => x.Database.Url).NotEmpty().Url();
RuleFor(x => x.Database.Name).NotEmpty();
}
}
}
@@ -1,32 +0,0 @@
using FluentValidation;
using zero.Core.Entities.Setup;
using zero.Core.Extensions;
namespace zero.Core.Validation
{
public class SetupSaveValidator : AbstractValidator<SetupSave>
{
public SetupSaveValidator()
{
When(x => x.Part == SetupSavePart.ValidateUser, () =>
{
RuleFor(x => x.User).NotNull();
RuleFor(x => x.User.Email).NotEmpty().Email();
RuleFor(x => x.User.Name).MaximumLength(40).NotEmpty();
RuleFor(x => x.User.Password).MaximumLength(1024); // TODO password policy
});
When(x => x.Part == SetupSavePart.ValidateApplication, () =>
{
RuleFor(x => x.AppName).MaximumLength(40).NotEmpty();
});
When(x => x.Part == SetupSavePart.ValidateDatabase, () =>
{
RuleFor(x => x.Database.Url).NotEmpty().Url();
RuleFor(x => x.Database.Name).NotEmpty();
});
}
}
}
+10 -4
View File
@@ -2,6 +2,8 @@
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using zero.Core;
using zero.Core.Api;
using zero.Core.Entities;
using zero.Core.Entities.Setup;
using zero.Web.Controllers;
@@ -10,9 +12,12 @@ namespace zero.Web.Setup
[AllowAnonymous]
public class SetupController : BackofficeController
{
public SetupController(IZeroConfiguration config) : base(config)
{
protected ISetupApi Api { get; private set; }
public SetupController(IZeroConfiguration config, ISetupApi api) : base(config)
{
Api = api;
}
public IActionResult Index()
@@ -21,9 +26,10 @@ namespace zero.Web.Setup
}
[HttpPost]
public async Task<IActionResult> Save(SetupSave model)
public async Task<IActionResult> Install([FromBody] SetupModel model)
{
return Ok();
EntityChangeResult<SetupModel> result = await Api.Install(model);
return Json(result);
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
:root
{
// accent colors
--color-primary: #00aea2;
--color-primary: #f9c202;
--color-secondary: #2f3a43;
--color-negative: #d82853;
+1 -1
View File
@@ -15,7 +15,7 @@ $font-headline-name: 'TwCenMt';
@include font-face($font-headline-name, '/Assets/Fonts/TwCenMt/twcenmt-regular', 400);
@include font-face($font-headline-name, '/Assets/Fonts/TwCenMt/TwCenMt-bold', 700);
@include font-face($font-headline-name, '/Assets/Fonts/TwCenMt/twcenmt-bold', 700);
%font
{
+10 -8
View File
@@ -38,14 +38,16 @@ body
.app
{
width: 100%;
max-width: 520px;
background: var(--color-bg);
border-radius: 3px;
min-height: 600px;
position: relative;
z-index: 2;
padding: 40px;
height: 100%;
}
.app-headline
{
text-align: center;
color: white;
font-size: 64px;
font-weight: 400;
margin: -10vh 0 8vh;
}
body:before
+1 -1
View File
@@ -4,7 +4,7 @@
<p>With zero you can manage multiple applications with one installation. Start by adding your first application.</p>
<ui-property label="Application name" :vertical="true">
<input v-model="value.AppName" type="text" class="ui-input" maxlength="40" placeholder="Enter name" />
<input v-model="value.appName" type="text" class="ui-input" maxlength="40" placeholder="My super awesome app maybe?" />
</ui-property>
</div>
</template>
+2 -2
View File
@@ -4,11 +4,11 @@
<p>Zero uses <b>RavenDB</b> as its database. If you want a more sophisticated setup (i.e. cluster) you can override the IDocumentStore service which is registered as a singleton.</p>
<ui-property label="Instance URL" :vertical="true">
<input v-model="value.Database.Url" type="text" class="ui-input" placeholder="Enter URL to the database" />
<input v-model="value.database.url" type="text" class="ui-input" placeholder="Enter URL to the Raven instance" />
</ui-property>
<ui-property label="Database name" :vertical="true">
<input v-model="value.Database.Name" type="text" class="ui-input" placeholder="Name of the database" />
<input v-model="value.database.name" type="text" class="ui-input" placeholder="Name of the existing database" />
</ui-property>
</div>
</template>
+138 -7
View File
@@ -1,28 +1,97 @@
<template>
<div class="setup-step setup-step-install">
<div class="setup-step-finish-inner">
<img src="/Icons/bird.svg" class="bird" alt="bird" width="100" />
<div v-if="!errorMessage" class="setup-step-install-inner">
<div class="setup-step-install-progress"><i :style="{ width: progress + '%' }"></i></div>
<h2>Installing</h2>
</div>
<div v-if="errorMessage" class="setup-step-install-error">
<div class="setup-step-install-error-main">
<h2>Oh no!</h2>
<p>The setup of <b>zero</b> failed with following error(s):</p>
<pre class="setup-step-install-error-code"><code v-html="errorMessage"></code></pre>
</div>
<div class="setup-buttons">
<ui-button type="outline" :click="onBack" label="Go back" caret="left" caret-position="left" />
<ui-button label="Retry" :click="install" />
</div>
</div>
</div>
</template>
<script>
import UiButton from 'zerocomponents/buttons/button.vue'
import Axios from 'axios'
export default {
name: 'setupStepInstall',
props: {
value: Object,
onBack: {
type: Function,
required: true
},
onNext: {
type: Function,
required: true
}
},
components: { UiButton },
mounted ()
data: () => ({
errorMessage: null,
progress: 0
}),
mounted()
{
this.install();
},
methods: {
install()
{
this.errorMessage = null;
let interval = setInterval(() =>
{
if (++this.progress >= 100)
{
this.onNext();
clearInterval(interval);
}
}, 30);
Axios.post('/api/setup/install', this.value)
.then(response =>
{
})
.catch((error) =>
{
// The request was made and the server responded with a status code that falls out of the range of 2xx
if (error.response)
{
this.error(error.response.data);
}
// The request was made but no response was received
else if (error.request)
{
this.error(error.request);
}
else
{
this.error(error.message);
}
});
},
error(error)
{
this.errorMessage = error;
}
}
}
@@ -35,12 +104,74 @@
display: grid;
grid-template-rows: 1fr auto;
align-items: center;
justify-content: center;
justify-content: stretch;
}
.setup-step-install-inner
{
text-align: center;
.bird
h2
{
margin-bottom: 30px;
margin: 30px 0 0;
}
}
.setup-step-install-progress
{
position: relative;
margin: 0 auto;
width: 80%;
height: 12px;
border-radius: 6px;
background: var(--color-bg-light);
overflow: hidden;
i
{
background: var(--color-primary);
transition: width 0.1s ease;
display: block;
height: 100%;
}
}
.setup-step-install-error
{
display: grid;
grid-template-rows: 1fr auto;
align-items: stretch;
width: 100%;
height: 100%;
h2
{
color: var(--color-negative);
}
div
{
width: 100%;
}
}
.setup-step-install-error-code
{
background: var(--color-bg-light);
padding: 20px;
font-size: 14px;
line-height: 20px;
width: 440px;
max-height: 50vh;
overflow: scroll;
font-family: 'Lucida Console', Consolas, sans-serif;
margin: 0;
}
.setup-step-install-error-main
{
display: grid;
grid-template-rows: auto auto 1fr;
margin-bottom: 30px;
}
</style>
+3 -3
View File
@@ -4,15 +4,15 @@
<p>First we need some basic credentials from you to create a login. This login is the solely <b>superadmin</b> which is required to change critical application data.</p>
<ui-property label="Name" :vertical="true">
<input v-model="value.User.Name" type="text" class="ui-input" maxlength="40" placeholder="What's your name?" />
<input v-model="value.user.name" type="text" class="ui-input" maxlength="40" placeholder="What's your name? It doesn't have to be your real name" />
</ui-property>
<ui-property label="Email" :vertical="true">
<input v-model="value.User.Email" type="text" class="ui-input" placeholder="Your email will be used as your login" />
<input v-model="value.user.email" type="text" class="ui-input" placeholder="Your email will be used as your login" />
</ui-property>
<ui-property label="Password" :vertical="true">
<input v-model="value.User.Password" type="password" class="ui-input" autocomplete="off" maxlength="1024" placeholder="As long as you want but at last 8 characters" />
<input v-model="value.user.password" type="password" class="ui-input" autocomplete="off" maxlength="1024" placeholder="From 8 characters to a galaxy far, far away" />
</ui-property>
</div>
</template>
+39 -21
View File
@@ -1,9 +1,12 @@
<template>
<div class="app setup">
<component v-bind:is="steps[step]" v-model="model" />
<div class="setup-buttons" v-if="step < 3">
<ui-button v-if="step > 0" type="outline" label="Go back" :click="prev" caret="left" caret-position="left" />
<ui-button label="Next" :click="next" />
<div class="app">
<h1 class="app-headline">zero</h1>
<div class="setup">
<component v-bind:is="steps[step]" v-model="model" :on-back="prev" :on-next="next" />
<div class="setup-buttons" v-if="step < 3">
<ui-button v-if="step > 0" type="outline" label="Go back" :click="prev" caret="left" caret-position="left" />
<ui-button :label="nextLabel" :click="next" />
</div>
</div>
</div>
</template>
@@ -24,23 +27,30 @@
components: { UiButton, StepUser, StepApplication, StepDatabase, StepInstall, StepFinish },
data: () => ({
step: 3,
steps: [ 'step-user', 'step-application', 'step-database', 'step-install', 'step-finish' ],
model: {
AppName: null,
Part: null,
User: {
Name: null,
Email: null,
Password: null
},
Database: {
Url: 'http://localhost:9800',
Name: null
}
}
step: 0,
steps: ['step-user', 'step-database', 'step-application', 'step-install', 'step-finish'],
model: { "appName": "Brothers", "user": { "name": "Tobi", "email": "yolo@brothers.studio", "password": "yolo" }, "database": { "url": "http://localhost:9800", "name": "zero" } }
//model: {
// appName: null,
// user: {
// name: null,
// email: null,
// password: null
// },
// database: {
// url: 'http://localhost:9800',
// name: null
// }
//}
}),
computed: {
nextLabel()
{
return this.step < 2 ? 'Next' : 'Install application';
}
},
mounted()
{
@@ -52,7 +62,7 @@
{
if (this.step + 1 >= this.steps.length)
{
return;
}
this.step += 1;
@@ -80,6 +90,14 @@
display: grid;
grid-template-rows: 1fr auto;
align-items: stretch;
max-width: 100%;
width: 520px;
background: var(--color-bg);
border-radius: 3px;
min-height: 600px;
position: relative;
z-index: 2;
padding: 40px;
}
.setup-step
+4
View File
@@ -12,6 +12,7 @@ using Newtonsoft.Json.Serialization;
using System;
using System.Threading.Tasks;
using zero.Core;
using zero.Core.Api;
namespace zero.Web
{
@@ -86,6 +87,9 @@ namespace zero.Web
});
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
// TODO move registration into core
services.AddTransient<ISetupApi, SetupApi>();
}
+1 -15
View File
@@ -2,19 +2,5 @@
isDebug: process.env.NODE_ENV !== 'production',
apiBase: '/api',
countries: [
{ value: 'austria', text: 'Austria' },
{ value: 'germany', text: 'Germany' },
{ value: 'usa', text: 'USA' }
],
categories: [
{ value: 'unknown', text: '' },
{ value: 'socialInsurance', text: 'SVA' },
{ value: 'tax', text: 'Tax' },
{ value: 'payout', text: 'Payout' },
{ value: 'payment', text: 'Payment' }
]
apiBase: '/api'
};
File diff suppressed because one or more lines are too long
+1
View File
@@ -21,6 +21,7 @@
<Content Remove="wwwroot/Temp/**" />
<Content Remove="wwwroot/Media/**" />
<Content Remove="web.release.config" />
<None Remove="wipe.js" />
<None Include="web.release.config" />
</ItemGroup>