Merge http4k

This commit is contained in:
2020-08-13 19:37:39 +02:00
parent b41b2103f0
commit 24aabd494e
176 changed files with 4965 additions and 8607 deletions
+49
View File
@@ -0,0 +1,49 @@
package be.simplenotes.app
import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig
import be.simplenotes.shared.config.ServerConfig
import java.util.*
import java.util.concurrent.TimeUnit
object Config {
//region Config loading
private val properties: Properties = javaClass
.getResource("/application.properties")
.openStream()
.use {
Properties().apply { load(it) }
}
private val env = System.getenv()
private fun value(key: String): String =
env[key.toUpperCase().replace(".", "_")]
?: properties.getProperty(key)
?: error("Missing config key $key")
//endregion
val jwtConfig
get() = JwtConfig(
secret = value("jwt.secret"),
validity = value("jwt.validity").toLong(),
timeUnit = TimeUnit.HOURS,
)
val dataSourceConfig
get() = DataSourceConfig(
jdbcUrl = value("jdbcUrl"),
driverClassName = value("driverClassName"),
username = value("username"),
password = value("password"),
maximumPoolSize = value("maximumPoolSize").toInt(),
connectionTimeout = value("connectionTimeout").toLong()
)
val serverConfig
get() = ServerConfig(
host = value("host"),
port = value("port").toInt(),
)
}
+32
View File
@@ -0,0 +1,32 @@
package be.simplenotes.app
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.http4k.routing.RoutingHttpHandler
import org.http4k.server.ConnectorBuilder
import org.http4k.server.Jetty
import org.http4k.server.ServerConfig
import org.http4k.server.asServer
import org.slf4j.LoggerFactory
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
class Server(
private val config: SimpleNotesServeConfig,
private val serverConfig: ServerConfig,
private val router: RoutingHttpHandler,
) {
fun start() {
router.asServer(serverConfig).start()
LoggerFactory.getLogger(javaClass).info("Listening on http://${config.host}:${config.port}")
}
}
fun serverConfig(config: SimpleNotesServeConfig): ServerConfig {
val builder: ConnectorBuilder = { server: Server ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
return Jetty(config.port, builder)
}
+92
View File
@@ -0,0 +1,92 @@
package be.simplenotes.app
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.app.views.BaseView
import be.simplenotes.app.views.NoteView
import be.simplenotes.app.views.UserView
import be.simplenotes.domain.domainModule
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig
import org.http4k.core.RequestContexts
import org.koin.core.context.startKoin
import org.koin.core.qualifier.qualifier
import org.koin.dsl.module
import org.slf4j.LoggerFactory
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
fun main() {
val koin = startKoin {
modules(
persistanceModule,
configModule,
domainModule,
serverModule,
userModule,
baseModule,
noteModule,
)
}.koin
val dataSourceConfig = koin.get<DataSourceConfig>()
val jwtConfig = koin.get<JwtConfig>()
val serverConfig = koin.get<SimpleNotesServeConfig>()
val logger = LoggerFactory.getLogger("SimpleNotes")
logger.info("datasource: $dataSourceConfig")
logger.info("jwt: $jwtConfig")
logger.info("server: $serverConfig")
val migrations = koin.get<DbMigrations>()
migrations.migrate()
koin.get<Server>().start()
}
val serverModule = module {
single { Server(get(), get(), get()) }
single<StaticFileResolver> { StaticFileResolverImpl() }
single {
Router(
get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier),
get()
)()
}
single { serverConfig(get()) }
single { RequestContexts() }
single(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get())() }
single(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get())() }
}
val userModule = module {
single { UserController(get(), get()) }
single { UserView(get()) }
}
val baseModule = module {
single { BaseController(get()) }
single { BaseView(get()) }
}
val noteModule = module {
single { NoteController(get(), get()) }
single { NoteView(get()) }
}
val configModule = module {
single { Config.dataSourceConfig }
single { Config.jwtConfig }
single { Config.serverConfig }
}
@@ -0,0 +1,13 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.views.BaseView
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
class BaseController(private val view: BaseView) {
fun index(@Suppress("UNUSED_PARAMETER") request: Request, jwtPayload: JwtPayload?) =
Response(OK).html(view.renderHome(jwtPayload))
}
@@ -0,0 +1,96 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.NoteView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.markdown.InvalidMeta
import be.simplenotes.domain.usecases.markdown.MissingMeta
import be.simplenotes.domain.usecases.markdown.ValidationError
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.routing.path
import java.util.*
import kotlin.math.abs
class NoteController(
private val view: NoteView,
private val noteService: NoteService,
) {
fun new(request: Request, jwtPayload: JwtPayload): Response {
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(jwtPayload))
val markdownForm = request.form("markdown") ?: ""
return noteService.create(jwtPayload.userId, markdownForm).fold({
val html = when (it) {
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
is ValidationError -> view.noteEditor(jwtPayload, validationErrors = it.validationErrors, textarea = markdownForm)
}
Response(BAD_REQUEST).html(html)
}, {
Response.redirect("/notes/${it.uuid}")
})
}
fun list(request: Request, jwtPayload: JwtPayload): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage)
return Response(OK).html(view.notes(jwtPayload, notes, currentPage, pages))
}
fun note(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
if (request.method == Method.POST && request.form("delete") != null) {
return if (noteService.delete(jwtPayload.userId, noteUuid))
Response.redirect("/notes") // FIXME: flash cookie to show success ?
else
Response(NOT_FOUND) // FIXME: show an error
}
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(jwtPayload, note))
}
fun edit(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
if (request.method == Method.GET) {
return Response(OK).html(view.noteEditor(jwtPayload, textarea = note.markdown))
}
val markdownForm = request.form("markdown") ?: ""
return noteService.update(jwtPayload.userId, note.uuid, markdownForm).fold({
val html = when (it) {
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
is ValidationError -> view.noteEditor(jwtPayload, validationErrors = it.validationErrors, textarea = markdownForm)
}
Response(BAD_REQUEST).html(html)
}, {
Response.redirect("/notes/${note.uuid}")
})
}
private fun Request.uuidPath(): UUID? {
val uuidPath = path("uuid")!!
return try {
UUID.fromString(uuidPath)!!
} catch (e: IllegalArgumentException) {
null
}
}
}
@@ -0,0 +1,112 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.isSecure
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.UserView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.login.*
import be.simplenotes.domain.usecases.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.register.RegisterForm
import be.simplenotes.domain.usecases.register.UserExists
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.core.cookie.Cookie
import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie
class UserController(
private val userService: UserService,
private val userView: UserView,
) {
fun register(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html(
userView.register(jwtPayload)
)
val result = userService.register(request.registerForm())
return result.fold(
{
val html = when (it) {
UserExists -> userView.register(
jwtPayload,
error = "User already exists"
)
is InvalidRegisterForm ->
userView.register(
jwtPayload,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{
Response.redirect("/login")
}
)
}
private fun Request.registerForm() = RegisterForm(form("username"), form("password"))
private fun Request.loginForm(): LoginForm = registerForm()
fun login(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html(
userView.login(jwtPayload)
)
val result = userService.login(request.loginForm())
return result.fold(
{
val html = when (it) {
Unregistered ->
userView.login(
jwtPayload,
error = "User does not exist"
)
WrongPassword ->
userView.login(
jwtPayload,
error = "Wrong password"
)
is InvalidLoginForm ->
userView.login(
jwtPayload,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{ token ->
Response.redirect("/").loginCookie(token, request.isSecure())
}
)
}
private fun Response.loginCookie(token: Token, secure: Boolean): Response {
// FIXME: expires
// val expiresAt = JWT.decode(token).expiresAt
// LocalDateTime.ofEpochSecond(expiresAt.time, 0)
return this.cookie(
Cookie(
name = "Authorization",
value = "Bearer $token",
path = "/",
httpOnly = true,
sameSite = SameSite.Lax,
secure = secure
)
)
}
fun logout(@Suppress("UNUSED_PARAMETER") request: Request) = Response.redirect("/")
.invalidateCookie("Authorization")
}
@@ -0,0 +1,13 @@
package be.simplenotes.app.extensions
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.MOVED_PERMANENTLY
fun Response.html(html: String) = body(html).header("Content-Type", "text/html; charset=utf-8")
fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)
fun Request.isSecure() = header("X-Forwarded-Proto")?.contains("https") ?: false
+45
View File
@@ -0,0 +1,45 @@
package be.simplenotes.app.filters
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter
import org.http4k.core.Request
import org.http4k.core.RequestContexts
import org.http4k.core.Response
import org.http4k.core.cookie.cookie
enum class AuthType {
Optional, Required
}
private const val authKey = "auth"
class AuthFilter(
private val extractor: JwtPayloadExtractor,
private val authType: AuthType,
private val ctx: RequestContexts
) {
operator fun invoke() = Filter { next ->
{
val jwtPayload = it.bearerToken()?.let { token -> extractor(token) }
when {
jwtPayload != null -> {
ctx[it][authKey] = jwtPayload
next(it)
}
authType == AuthType.Required -> Response.redirect("/login")
else -> next(it)
}
}
}
}
fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey]
private fun Request.bearerToken(): String? = cookie("Authorization")
?.value
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()
@@ -0,0 +1,26 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.Response
import org.http4k.core.Status
import org.slf4j.LoggerFactory
object ErrorFilter {
private val logger = LoggerFactory.getLogger(javaClass)
operator fun invoke(): Filter = Filter { next ->
{
try {
val response = next(it)
if (response.status == Status.NOT_FOUND) Response(Status.NOT_FOUND).body("TODO(NOT_FOUND)")
else response
} catch (e: Exception) {
logger.error(e.stackTraceToString())
Response(Status.INTERNAL_SERVER_ERROR).body("TODO(INTERNAL_SERVER_ERROR)")
} catch (e: NotImplementedError) {
logger.error(e.stackTraceToString())
Response(Status.INTERNAL_SERVER_ERROR).body("TODO(NotImplementedError)")
}
}
}
}
@@ -0,0 +1,19 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request
object ImmutableFilter {
operator fun invoke(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
if (request.method == Method.GET)
response.header("Cache-Control", "public, max-age=31536000, immutable")
else response
}
}
}
}
@@ -0,0 +1,20 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
object SecurityFilter {
operator fun invoke(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
.header("X-Content-Type-Options", "nosniff")
if (response.header("Content-Type")?.contains("text/html") == true)
response.header("Content-Security-Policy", "default-src 'self'")
else response
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.ImmutableFilter
import be.simplenotes.app.filters.SecurityFilter
import be.simplenotes.app.filters.jwtPayload
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.*
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.filter.ResponseFilters
import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.*
class Router(
private val baseController: BaseController,
private val userController: UserController,
private val noteController: NoteController,
private val requiredAuth: Filter,
private val optionalAuth: Filter,
private val contexts: RequestContexts,
) {
operator fun invoke(): RoutingHttpHandler {
val basicRoutes = routes(
ImmutableFilter().then(static(ResourceLoader.Classpath(("/static")))),
)
fun public(request: Request, handler: PublicHandler) = handler(request, request.jwtPayload(contexts))
fun protected(request: Request, handler: ProtectedHandler) = handler(request, request.jwtPayload(contexts)!!)
val publicRoutes: RoutingHttpHandler = routes(
"/" bind GET to { public(it, baseController::index) },
"/register" bind GET to { public(it, userController::register) },
"/register" bind POST to { public(it, userController::register) },
"/login" bind GET to { public(it, userController::login) },
"/login" bind POST to { public(it, userController::login) },
"/logout" bind POST to userController::logout,
)
val protectedRoutes = routes(
"/account" bind GET to { TODO() },
"/export" bind POST to { TODO() },
"/notes" bind GET to { protected(it, noteController::list) },
"/notes/new" bind GET to { protected(it, noteController::new) },
"/notes/new" bind POST to { protected(it, noteController::new) },
"/notes/{uuid}" bind GET to { protected(it, noteController::note) },
"/notes/{uuid}" bind POST to { protected(it, noteController::note) },
"/notes/{uuid}/edit" bind GET to { protected(it, noteController::edit) },
"/notes/{uuid}/edit" bind POST to { protected(it, noteController::edit) },
)
val routes = routes(
basicRoutes,
optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes),
)
val globalFilters = ErrorFilter()
.then(InitialiseRequestContext(contexts))
.then(SecurityFilter())
.then(ResponseFilters.GZip())
return globalFilters.then(routes)
}
}
private typealias PublicHandler = (Request, JwtPayload?) -> Response
private typealias ProtectedHandler = (Request, JwtPayload) -> Response
@@ -0,0 +1,24 @@
package be.simplenotes.app.utils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration
interface StaticFileResolver {
fun resolve(name: String): String?
}
class StaticFileResolverImpl : StaticFileResolver {
private val mappings: Map<String, String>
init {
val json = Json(JsonConfiguration.Stable)
val manifest = javaClass.getResource("/css-manifest.json").readText()
val manifestObject = json.parseJson(manifest).jsonObject
val keys = manifestObject.keys
mappings = keys.map {
it to "/${manifestObject[it]!!.primitive.content}"
}.toMap()
}
override fun resolve(name: String) = mappings[name]
}
+18
View File
@@ -0,0 +1,18 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.div
import kotlinx.html.h1
class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun renderHome(jwtPayload: JwtPayload?) = renderPage(title = "Home", jwtPayload = jwtPayload) {
div("centered container mx-auto flex justify-center items-center") {
div("bg-gray-800 md:w-1/3 w-full rounded-lg m-4 p-6 text-center") {
h1("text-3xl") { +"SimpleNotes" }
div("text-teal-400") { +"Welcome" }
div("text-gray-200") { +"TODO" }
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.Alert
import be.simplenotes.app.views.components.alert
import be.simplenotes.app.views.components.submitButton
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun noteEditor(
jwtPayload: JwtPayload,
error: String? = null,
textarea: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage(title = "New note", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
// TODO: error
error?.let { alert(Alert.Warning, error) }
validationErrors.forEach {
alert(Alert.Warning, it.dataPath.substringAfter('.') + ": " + it.message)
}
form(method = FormMethod.post) {
textArea(classes = "w-full bg-gray-800 p-5 outline-none font-mono") {
attributes.also {
it["rows"] = "20"
it["id"] = "markdown"
it["name"] = "markdown"
it["aria-label"] = "markdown text area"
it["spellcheck"] = "false"
}
textarea?.let {
+it
} ?: +"""
|---
|title: ''
|tags: []
|---
|
""".trimMargin("|")
}
submitButton("Save")
}
}
}
fun notes(jwtPayload: JwtPayload, notes: List<PersistedNoteMetadata>, currentPage: Int, numberOfPages: Int) =
renderPage(title = "Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" }
a(
href = "/notes/new",
classes = "text-gray-800 bg-green-500 hover:bg-green-700 " +
"inline ml-2 text-md font-semibold rounded px-4 py-2"
) { +"New" }
}
if (notes.isNotEmpty()) {
ul {
notes.forEach { (title, tags, _, uuid) ->
li("flex justify-between") {
a(classes = "text-blue-200 text-xl hover:underline", href = "/notes/${uuid}") {
+title
}
span {
tags.forEach {
span("tag ml-2") { +"#$it" }
}
}
}
}
}
if (numberOfPages > 1)
pagination(currentPage, numberOfPages)
} else
span { +"No notes yet" } // FIXME if too far in pagination, it it displayed
}
}
private fun DIV.pagination(currentPage: Int, numberOfPages: Int) {
val links = mutableListOf<Pair<String, String>>()
//if (currentPage > 1) links += "Previous" to "?page=${currentPage - 1}"
links += (1..numberOfPages).map { "$it" to "?page=$it" }
//if (currentPage < numberOfPages) links += "Next" to "?page=${currentPage + 1}"
nav("pages") {
links.forEach { (name, href) ->
a(href, classes = if (name == currentPage.toString()) "active" else null) { +name }
}
}
}
fun renderedNote(jwtPayload: JwtPayload, note: PersistedNote) = renderPage(note.meta.title, jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.meta.title }
span {
note.meta.tags.forEach {
span("tag ml-2") { +"#$it" }
}
}
}
span("flex justify-end mb-4") {
a(
href = "/notes/${note.uuid}/edit",
classes = "mx-2 bg-teal-500 hover:bg-teal-600 focus:bg-teal-600" +
" focus:outline-none text-white font-bold py-2 px-4 rounded"
) { +"Edit" }
form(method = FormMethod.post, classes = "inline") {
button(
type = ButtonType.submit,
name = "delete",
classes = "mx-2 bg-red-500 hover:bg-red-600 focus:bg-red-600" +
" focus:outline-none text-white font-bold py-2 px-4 rounded"
) { +"Delete" }
}
}
div {
attributes["id"] = "note"
unsafe {
+note.html
}
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.Alert
import be.simplenotes.app.views.components.alert
import be.simplenotes.app.views.components.input
import be.simplenotes.app.views.components.submitButton
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
class UserView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun register(
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = accountForm("Register", jwtPayload, error, validationErrors, "Create an account", "Register") {
+"Already have an account? "
a(href = "/login", classes = "no-underline text-blue-500 font-bold") { +"Sign In" }
}
fun login(
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
new: Boolean = false,
) = accountForm("Login", jwtPayload, error, validationErrors, "Sign In", "Sign In", new) {
+"Don't have an account yet? "
a(href = "/register", classes = "no-underline text-blue-500 font-bold") { +"Create an account" }
}
private fun accountForm(
title: String,
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
h1: String,
submit: String,
new: Boolean = false,
footer: FlowContent.() -> Unit,
) = renderPage(title = title, jwtPayload = jwtPayload) {
div("centered container mx-auto flex justify-center items-center") {
div("w-full md:w-1/2 lg:w-1/3 m-4") {
h1("font-semibold text-lg mb-6 text-center") { +h1 }
div("p-8 mb-6") {
if (new) alert(Alert.Success, "Your account has been created")
error?.let { alert(Alert.Warning, error) }
form(method = FormMethod.post) {
input(
id = "username",
placeholder = "Username",
autoComplete = "username",
error = validationErrors.find { it.dataPath == ".username" }?.message
)
input(
id = "password",
placeholder = "Password",
autoComplete = "new-password",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
submitButton(submit)
}
}
div("text-center") {
p("text-gray-200 text-sm") {
footer()
}
}
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.navbar
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
import kotlinx.html.stream.appendHTML
abstract class View(private val staticFileResolver: StaticFileResolver) {
private val styles = staticFileResolver.resolve("styles.css")!!
fun renderPage(
title: String,
description: String? = null,
jwtPayload: JwtPayload?,
body: BODY.() -> Unit = {},
) = buildString {
appendLine("<!DOCTYPE html>")
appendHTML().html {
attributes["lang"] = "en"
head {
meta(charset = "UTF-8")
meta(name = "viewport", content = "width=device-width, initial-scale=1")
title("$title - SimpleNotes")
description?.let { meta(name = "description", content = it) }
link(rel = "stylesheet", href = styles)
link(rel = "shortcut icon", href="/favicon.ico", type = "image/x-icon")
}
body("bg-gray-900 text-white") {
navbar(jwtPayload)
main { this@body.body() }
}
}
}
}
@@ -0,0 +1,19 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
fun FlowContent.alert(type: Alert, title: String, details: String? = null) {
val colors = when (type) {
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
}
div("$colors px-4 py-3 mb-4 rounded relative") {
attributes["role"] = "alert"
strong("font-bold") { +title }
details?.let { span("block sm:inline") { +details } }
}
}
enum class Alert {
Success, Warning
}
@@ -0,0 +1,37 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
fun FlowContent.input(
type: InputType = InputType.text,
placeholder: String,
id: String,
autoComplete: String? = null,
error: String? = null
) {
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
div("mb-8") {
input(
type = type,
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
) {
attributes["placeholder"] = placeholder
attributes["aria-label"] = placeholder
attributes["name"] = id
attributes["id"] = id
autoComplete?.let { attributes["autocomplete"] = it }
}
error?.let { p("mt-2 text-red-500 text-sm italic") { +"$placeholder $error" } }
}
}
fun FlowContent.submitButton(text: String) {
div("flex items-center mt-6") {
button(
type = submit,
classes = "bg-teal-500 hover:bg-teal-600 focus:bg-teal-600" +
" w-full focus:outline-none text-white font-bold py-2 px-4 rounded"
) { +text }
}
}
@@ -0,0 +1,29 @@
package be.simplenotes.app.views.components
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
fun BODY.navbar(jwtPayload: JwtPayload?) {
nav("nav bg-teal-700 shadow-md flex items-center justify-between px-4") {
a(href = "/", classes = "text-2xl text-gray-100 font-bold") { +"SimpleNotes" }
ul {
if (jwtPayload != null) {
li("inline text-gray-100 ml-2 text-md font-semibold") {
a(href = "/notes") { +"Notes" }
}
li(
"text-gray-800 bg-green-500 hover:bg-green-700" +
" inline ml-2 text-md font-semibold rounded px-4 py-2"
) {
form(classes = "inline", action = "/logout", method = FormMethod.post) {
button(type = ButtonType.submit) { +"Logout" }
}
}
} else {
li("inline text-gray-100 pl-2 text-md font-semibold") {
a(href = "/login") { +"Sign In" }
}
}
}
}
}