Initial commit

This commit is contained in:
scastro committed 2026-10-24 09:52:38 -06:00
commit f68d7cfdaa
100 files changed
+12614

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Kotlin ###
.kotlin
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
/node_modules/
/.local.yaml
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
+1
View File
@@ -0,0 +1 @@
welcome1
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<component name="CopyrightManager">
<settings default="default">
<module2copyright>
<element module="Project Files" copyright="default" />
</module2copyright>
</settings>
</component>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="0@172.16.51.17" uuid="ef12f723-6f4c-4952-a1a5-c0d2aa237e72">
<driver-ref>redis</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>jdbc.RedisDriver</jdbc-driver>
<jdbc-url>jdbc:redis://172.16.51.17:6379/0</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="liberica-25" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+43
View File
@@ -0,0 +1,43 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="6">
<item index="0" class="java.lang.String" itemvalue="x-data" />
<item index="1" class="java.lang.String" itemvalue="@htmx:after-request.camel" />
<item index="2" class="java.lang.String" itemvalue="x-on:turnstile:ready.window" />
<item index="3" class="java.lang.String" itemvalue="x-on:before-swap.camel" />
<item index="4" class="java.lang.String" itemvalue="x-on:after-swap.camel" />
<item index="5" class="java.lang.String" itemvalue="x-on:after-request.camel" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
<inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="7">
<item index="0" class="java.lang.String" itemvalue="nobr" />
<item index="1" class="java.lang.String" itemvalue="noembed" />
<item index="2" class="java.lang.String" itemvalue="comment" />
<item index="3" class="java.lang.String" itemvalue="noscript" />
<item index="4" class="java.lang.String" itemvalue="embed" />
<item index="5" class="java.lang.String" itemvalue="script" />
<item index="6" class="java.lang.String" itemvalue="link" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="customHeaders">
<set>
<option value="HX-Redirect" />
</set>
</option>
</inspection_tool>
</profile>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.21" />
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectResources">
<resource url="http://www.ultraq.net.nz/thymeleaf/layout" location="$USER_HOME$/.gradle/caches/modules-2/files-2.1/nz.net.ultraq.thymeleaf/thymeleaf-layout-dialect/4.0.1/9510c824484045cdfdd98c39d374ec94ac379cc3/thymeleaf-layout-dialect-4.0.1.jar!/nz/net/ultraq/thymeleaf/layoutdialect/LayoutDialect.xml" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="liberica-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+165
View File
@@ -0,0 +1,165 @@
import com.github.gradle.node.npm.task.NpmTask
import org.gradle.jvm.tasks.Jar
import org.gradle.language.jvm.tasks.ProcessResources
import org.springframework.boot.buildpack.platform.build.PullPolicy
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
import org.springframework.boot.gradle.tasks.bundling.BootJar
import org.springframework.boot.gradle.tasks.run.BootRun
plugins {
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
id("com.github.node-gradle.node") version "7.1.0"
kotlin("jvm") version "2.4.21"
kotlin("plugin.spring") version "2.4.21"
application
java
}
group = "com.redinterglobal"
version = "1.3.4-SNAPSHOT"
repositories {
mavenCentral()
maven { url = uri("https://git.redinterglobal.com/api/packages/scastro/maven") }
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-jackson")
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-mail")
implementation("com.digitalsanctuary:ds-spring-cf-turnstile:2.1.0")
implementation("com.github.ben-manes.caffeine:caffeine")
implementation("io.github.oshai:kotlin-logging-jvm:8.0.4")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:4.0.1")
implementation("io.github.wimdeblauwe:htmx-spring-boot-thymeleaf:6.0.1") {
exclude(group = "org.springframework.boot")
}
implementation("com.redinterglobal:thymepack:1.52-SNAPSHOT")
implementation("com.redinterglobal:sdolib:1.6.6-SNAPSHOT")
implementation(platform("org.zalando:logbook-bom:4.2.0"))
implementation("org.zalando:logbook-spring-boot-starter")
implementation("org.zalando:logbook-core")
implementation("org.zalando:logbook-spring")
implementation("org.zalando:logbook-json")
implementation(kotlin("reflect"))
implementation("tools.jackson.module:jackson-module-kotlin")
developmentOnly("org.springframework.boot:spring-boot-devtools")
testImplementation(kotlin("test"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.springframework:spring-test")
testImplementation("org.mockito:mockito-core")
}
kotlin {
jvmToolchain(25)
}
tasks.named<Test>("test") {
useJUnitPlatform()
dependsOn(processPageResources)
classpath += files(generatedResourcesDir)
}
node {
download = true
version = "24.21.0"
}
val npmRunBuild = tasks.register<NpmTask>("npmRunBuild") {
description = "Build webpack"
args = listOf("run", "build")
dependsOn("npmInstall")
inputs.files(fileTree("node_modules"))
inputs.files(fileTree("src/main/resources/js"))
inputs.files(fileTree("src/main/resources/styles"))
inputs.files(fileTree("src/main/resources/templates"))
inputs.file("package.json")
inputs.file("webpack.config.js")
outputs.dir(layout.buildDirectory.dir("resources/main/static/dist"))
}
val generatedResourcesDir = layout.buildDirectory.dir("generated-resources/main")
val processPageResources = tasks.register<Copy>("processPageResources") {
from("src/main/resources/templates") {
into("templates")
}
from("src/main/resources/static") {
into("static")
}
into(generatedResourcesDir)
}
tasks.named<ProcessResources>("processResources") {
exclude("templates/**", "js/**", "styles/**", "static/**")
}
npmRunBuild.configure {
mustRunAfter(tasks.named("processResources"))
}
tasks.named<BootJar>("bootJar") {
dependsOn(npmRunBuild, processPageResources)
from(generatedResourcesDir) {
into("BOOT-INF/classes")
}
}
tasks.named<Jar>("jar") {
dependsOn(npmRunBuild, processPageResources)
from(generatedResourcesDir)
}
tasks.named("resolveMainClassName") {
dependsOn(npmRunBuild)
}
tasks.named<BootRun>("bootRun") {
dependsOn(npmRunBuild, processPageResources)
classpath += files(generatedResourcesDir)
}
tasks.named<BootBuildImage>("bootBuildImage") {
imagePlatform = "linux/amd64"
builder = "paketobuildpacks/builder-noble-java-tiny:0.0.199"
pullPolicy = PullPolicy.ALWAYS
environment.put("BP_JVM_VERSION", "25")
val repo = "git.redinterglobal.com/scastro/${project.name}"
imageName = "$repo:${project.version}"
tags = listOf("$repo:latest")
publish = true
docker {
publishRegistry {
url = "https://git.redinterglobal.com"
username = providers.gradleProperty("owner")
password = providers.gradleProperty("access_token")
}
}
}
+2
View File
@@ -0,0 +1,2 @@
kotlin.code.style=official
org.gradle.configuration-cache=true
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+112
View File
@@ -0,0 +1,112 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+8097
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
{
"name": "welcome1",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"main": "index.js",
"scripts": {
"devserver": "webpack serve --open --mode development",
"watch": "webpack --mode development --watch",
"build": "webpack --mode production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@fullhuman/postcss-purgecss": "^7.0.2",
"autoprefixer": "^10.6.1",
"chunks-webpack-plugin": "^13.1.0",
"css-loader": "^7.1.5",
"css-minimizer-webpack-plugin": "^8.0.0",
"mini-css-extract-plugin": "^2.10.2",
"postcss-loader": "^8.2.1",
"sass": "^1.105.1",
"sass-loader": "^17.0.1",
"source-map-loader": "^5.0.0",
"style-loader": "^4.0.0",
"webpack": "^5.111.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^6.0.0"
},
"dependencies": {
"@floating-ui/dom": "^1.8.0",
"@pqina/flip": "^1.8.4",
"@qr-platform/qr-code.js": "^0.20.14",
"@tanstack/hotkeys": "^0.17.0",
"alpinejs": "^3.17.5",
"bootstrap": "^6.0.0-alpha.1",
"datatables.net-dt": "^3.1.4",
"htmx.org": "4.0.0",
"moment": "^2.31.0",
"party-js": "^2.2.0",
"qrcode": "^1.5.4",
"vanilla-calendar-pro": "^3.4.0"
},
"allowScripts": {
"fsevents@2.3.3": true,
"@parcel/watcher@2.6.0": true
}
}
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "welcome1"
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome
import com.redinterglobal.welcome.config.WelcomeConfig
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.scheduling.annotation.EnableAsync
@SpringBootApplication
@EnableAsync
@EnableCaching
@EnableConfigurationProperties(WelcomeConfig::class)
class WelcomeApp
fun main(args: Array<String>) {
runApplication<WelcomeApp>(*args)
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.config
import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties
import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.web.client.RestClient
class DevelTurnstileValidationService(
properties: TurnstileConfigProperties,
metrics: TurnstileMetrics
) : TurnstileValidationService(RestClient.create(), properties, metrics) {
override fun validateTurnstileResponse(token: String?, remoteIp: String?): Boolean = true
}
@Configuration
@Profile("devel")
class DevelTurnstileConfiguration {
@Bean
fun turnstileValidationService(
properties: TurnstileConfigProperties,
metrics: TurnstileMetrics
): TurnstileValidationService = DevelTurnstileValidationService(properties, metrics)
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.config
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.Authentication
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler
import org.springframework.stereotype.Component
@Component
class MonitorAuthenticationSuccessHandler : SavedRequestAwareAuthenticationSuccessHandler() {
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication
) {
val email = (authentication.principal as? OAuth2User)
?.getAttribute<String>("email")
?.trim()
?.takeIf(String::isNotBlank)
if (email != null) {
response.addCookie(
Cookie(COOKIE_NAME, email).apply {
path = "/"
maxAge = COOKIE_MAX_AGE_SECONDS
isHttpOnly = true
secure = request.isSecure
setAttribute("SameSite", "Lax")
}
)
}
super.onAuthenticationSuccess(request, response, authentication)
}
companion object {
const val COOKIE_NAME = "monitor-email"
const val COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
}
}
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.config
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
import io.github.wimdeblauwe.htmx.spring.boot.security.HxRedirectToPageAccessDeniedHandler
import io.github.wimdeblauwe.htmx.spring.boot.security.HxRedirectToPageAuthenticationEntryPoint
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties
import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientPropertiesMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import jakarta.servlet.http.HttpServletRequest
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
@EnableWebSecurity
@Configuration
@EnableConfigurationProperties(OAuth2ClientProperties::class)
class SecurityConfiguration {
@Bean
fun unmappedControllerRequestMatcher(
requestMappingHandlerMapping: RequestMappingHandlerMapping
) = UnmappedControllerRequestMatcher(requestMappingHandlerMapping)
@Bean("monitorClientRegistrationRepository")
fun monitorClientRegistrationRepository(
properties: OAuth2ClientProperties
): ClientRegistrationRepository {
val registrations = OAuth2ClientPropertiesMapper(properties).asClientRegistrations()
return InMemoryClientRegistrationRepository(registrations.values.toList())
}
@Bean
fun monitorLoginHintAuthorizationRequestResolver(
@Qualifier("monitorClientRegistrationRepository") monitorClientRegistrationRepository: ClientRegistrationRepository
): OAuth2AuthorizationRequestResolver =
MonitorLoginHintAuthorizationRequestResolver(monitorClientRegistrationRepository)
@Bean
@Order(1)
fun welcomeMonitorSecurityConfiguration(
http: HttpSecurity,
@Qualifier("monitorClientRegistrationRepository") monitorClientRegistrationRepository: ClientRegistrationRepository,
monitorLoginHintAuthorizationRequestResolver: OAuth2AuthorizationRequestResolver,
unmappedControllerRequestMatcher: UnmappedControllerRequestMatcher,
monitorAuthenticationSuccessHandler: MonitorAuthenticationSuccessHandler
): SecurityFilterChain {
http {
securityMatcher("/monitor", "/monitor/**", "/oauth2/**", "/login/oauth2/**")
authorizeHttpRequests {
authorize(unmappedControllerRequestMatcher, permitAll)
authorize("/monitor/login", permitAll)
authorize("/monitor/login/select", permitAll)
authorize(anyRequest, hasRole("MONITOR"))
}
}
http.oauth2Login { oauth2Login ->
oauth2Login
.loginPage("/monitor/login")
.clientRegistrationRepository(monitorClientRegistrationRepository)
.authorizationEndpoint { authorizationEndpoint ->
authorizationEndpoint.authorizationRequestResolver(monitorLoginHintAuthorizationRequestResolver)
}
.successHandler(monitorAuthenticationSuccessHandler)
.userInfoEndpoint { userInfoEndpoint ->
userInfoEndpoint.userAuthoritiesMapper(monitorAuthoritiesMapper())
}
}
return http.build()
}
@Bean
fun monitorAuthoritiesMapper(): GrantedAuthoritiesMapper =
GrantedAuthoritiesMapper { authorities ->
authorities.toSet() + SimpleGrantedAuthority("ROLE_MONITOR")
}
@Bean
@Order(2)
fun welcomeAppConfiguration(
http: HttpSecurity,
turnstileAuthenticationProvider: TurnstileAuthenticationProvider,
turnstileValidationService: TurnstileValidationService,
loginAuthenticationFailureHandler: LoginAuthenticationFailureHandler,
unmappedControllerRequestMatcher: UnmappedControllerRequestMatcher
): SecurityFilterChain {
val invitationSuccessHandler = InvitationAuthenticationSuccessHandler("/")
http.authenticationProvider(turnstileAuthenticationProvider)
http {
authorizeHttpRequests {
authorize(unmappedControllerRequestMatcher, permitAll)
authorize("/error/**", permitAll)
authorize("/css/**", permitAll)
authorize("/js/**", permitAll)
authorize("/dist/**", permitAll)
authorize("/img/**", permitAll)
authorize("/favicon/**", permitAll)
authorize("/site.webmanifest", permitAll)
authorize(anyRequest, authenticated)
}
formLogin {
permitAll()
authenticationFailureHandler = loginAuthenticationFailureHandler
authenticationSuccessHandler = invitationSuccessHandler
loginPage = "/login"
loginProcessingUrl = "/login"
usernameParameter = "email"
passwordParameter = "cf-turnstile-response"
authenticationDetailsSource = ClientIpAuthenticationDetailsSource(turnstileValidationService)
failureUrl = "/login?error"
}
exceptionHandling {
authenticationEntryPoint = HxRedirectToPageAuthenticationEntryPoint(
LoginUrlAuthenticationEntryPoint("/login")
)
accessDeniedHandler = HxRedirectToPageAccessDeniedHandler()
}
}
return http.build()
}
}
internal data class PendingMonitorLoginHint(
val registrationId: String,
val email: String
)
internal class MonitorLoginHintAuthorizationRequestResolver(
clientRegistrationRepository: ClientRegistrationRepository
) : OAuth2AuthorizationRequestResolver {
private val delegate = DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository)
override fun resolve(request: HttpServletRequest): OAuth2AuthorizationRequest? =
addLoginHint(request, delegate.resolve(request))
override fun resolve(
request: HttpServletRequest,
clientRegistrationId: String
): OAuth2AuthorizationRequest? = addLoginHint(request, delegate.resolve(request, clientRegistrationId))
private fun addLoginHint(
request: HttpServletRequest,
authorizationRequest: OAuth2AuthorizationRequest?
): OAuth2AuthorizationRequest? {
val pendingHint = request.getSession(false)
?.getAttribute(PENDING_LOGIN_HINT_SESSION_ATTRIBUTE) as? PendingMonitorLoginHint
?: return authorizationRequest
val registrationId = authorizationRequest
?.attributes
?.get(OAuth2ParameterNames.REGISTRATION_ID)
?.toString()
?: return authorizationRequest
if (pendingHint.registrationId != registrationId) {
return authorizationRequest
}
request.session.removeAttribute(PENDING_LOGIN_HINT_SESSION_ATTRIBUTE)
val loginHint = pendingHint.email.trim().takeIf(String::isNotBlank)
?: return authorizationRequest
return OAuth2AuthorizationRequest.from(authorizationRequest)
.additionalParameters { parameters -> parameters[LOGIN_HINT_PARAMETER] = loginHint }
.build()
}
companion object {
const val PENDING_LOGIN_HINT_SESSION_ATTRIBUTE = "monitor.login.hint"
private const val LOGIN_HINT_PARAMETER = "login_hint"
}
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.config
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
import tools.jackson.databind.ObjectMapper
import com.redinterglobal.sdo.domain.User
import com.redinterglobal.sdo.domain.UserStatus
import com.redinterglobal.welcome.service.SdoWelcomeService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.context.annotation.Primary
import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.security.authentication.AuthenticationDetailsSource
import org.springframework.security.authentication.AuthenticationProvider
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.web.authentication.AuthenticationSuccessHandler
import org.springframework.security.web.authentication.AuthenticationFailureHandler
import org.springframework.security.web.DefaultRedirectStrategy
import org.springframework.security.web.RedirectStrategy
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
import org.springframework.stereotype.Component
import org.springframework.stereotype.Service
data class TurnstileLoginDetails(val clientIpAddress: String)
data class SdoAuthenticationPrincipal(
val user: User,
val registrationId: String
)
class ClientIpAuthenticationDetailsSource(
private val turnstileValidationService: TurnstileValidationService
) : AuthenticationDetailsSource<HttpServletRequest, TurnstileLoginDetails> {
override fun buildDetails(context: HttpServletRequest): TurnstileLoginDetails =
TurnstileLoginDetails(turnstileValidationService.getClientIpAddress(context))
}
@Component
class TurnstileAuthenticationProvider(
private val turnstileValidationService: TurnstileValidationService,
private val sdoWelcomeService: SdoWelcomeService
) : AuthenticationProvider {
override fun authenticate(authentication: Authentication): Authentication {
val email = authentication.name.trim()
val turnstileResponse = authentication.credentials?.toString().orEmpty()
val clientIpAddress = (authentication.details as? TurnstileLoginDetails)?.clientIpAddress
if (email.isBlank() ||
!turnstileValidationService.validateTurnstileResponse(turnstileResponse, clientIpAddress)
) {
throw BadCredentialsException("Login verification failed")
}
val account = try {
sdoWelcomeService.getSdoUser(email)
} catch (_: RuntimeException) {
throw BadCredentialsException("Login verification failed")
}
if (account.user.status == UserStatus.LOCKED) {
throw BadCredentialsException("User account is locked")
}
val principal = SdoAuthenticationPrincipal(account.user, account.registrationId)
return UsernamePasswordAuthenticationToken(
principal,
null,
AuthorityUtils.createAuthorityList("ROLE_USER")
).also { it.details = authentication.details }
}
override fun supports(authentication: Class<*>): Boolean =
UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
}
@Component
class LoginAuthenticationFailureHandler(
private val messageSource: MessageSource,
private val objectMapper: ObjectMapper,
) : AuthenticationFailureHandler {
private val redirectStrategy: RedirectStrategy = DefaultRedirectStrategy()
override fun onAuthenticationFailure(
request: HttpServletRequest,
response: HttpServletResponse,
exception: org.springframework.security.core.AuthenticationException,
) {
if (request.getHeader("HX-Request") == null) {
redirectStrategy.sendRedirect(request, response, "/login?error")
return
}
val message = messageSource.getMessage("login.error", null, LocaleContextHolder.getLocale())
val trigger = objectMapper.writeValueAsString(
mapOf("flash" to mapOf("target" to "#flash", "message" to message))
)
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.setHeader("HX-Reswap", "none")
response.setHeader("HX-Trigger", trigger)
}
}
class InvitationAuthenticationSuccessHandler(private val redirect: String) : AuthenticationSuccessHandler {
private val redirectHandler = SimpleUrlAuthenticationSuccessHandler(redirect)
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication
) {
val principal = authentication.principal as SdoAuthenticationPrincipal
request.session.setAttribute("sdoUser", principal.user)
request.session.setAttribute("registrationId", principal.registrationId)
if (request.getHeader("HX-Request") != null) {
response.setHeader("HX-Redirect", redirect)
response.status = HttpServletResponse.SC_OK
return
}
redirectHandler.onAuthenticationSuccess(request, response, authentication)
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.config
import jakarta.servlet.http.HttpServletRequest
import org.springframework.security.web.util.matcher.RequestMatcher
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
/**
* Lets requests with no MVC controller mapping reach Spring Boot's error handling.
*
* This keeps valid application routes protected while allowing anonymous visitors to
* receive an HTTP 404 page for a mistyped or obsolete URL.
*/
class UnmappedControllerRequestMatcher(
private val handlerMapping: RequestMappingHandlerMapping
) : RequestMatcher {
override fun matches(request: HttpServletRequest): Boolean =
handlerMapping.getHandler(request) == null
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.config
import org.springframework.boot.context.properties.ConfigurationProperties
data class SdoServer(
val registrationId: String,
val group: String? = null,
)
data class AppStore(
val logo: String,
val link: String,
val os: String,
val version: String
)
@ConfigurationProperties(prefix = "welcome")
class WelcomeConfig(
val otpDuration: Int = 180,
val appStores: List<AppStore>,
val shortLink: String? = null,
val sdoDomainServers: Map<String, SdoServer> = emptyMap(),
val idp: Map<String, String> = emptyMap()
)
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import com.redinterglobal.welcome.config.SdoAuthenticationPrincipal
import com.redinterglobal.welcome.service.SdoService
import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import java.security.Principal
@Controller
class CustomerLogoController(
private val sdoService: SdoService
) {
@GetMapping("/logo", "/monitor/logo")
fun logo(principal: Principal): ResponseEntity<ByteArray> {
val registrationId = if (principal is UsernamePasswordAuthenticationToken && principal.principal is SdoAuthenticationPrincipal) {
val user = principal.principal as SdoAuthenticationPrincipal
user.registrationId
} else if (principal is OAuth2AuthenticationToken) {
principal.principal.getAttribute("registrationId")
} else null
val logo = if (registrationId != null) sdoService.logo(registrationId) else null
return if (logo != null) ResponseEntity.ok().contentType(logo.mediaType).body(logo.media) else ResponseEntity.notFound().build()
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import com.redinterglobal.welcome.config.SdoAuthenticationPrincipal
import com.redinterglobal.welcome.service.OtpService
import com.redinterglobal.welcome.service.SdoService
import com.redinterglobal.welcome.service.StorageService
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest
import jakarta.servlet.http.HttpSession
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.SessionAttribute
import org.springframework.web.bind.annotation.SessionAttributes
private val logger = KotlinLogging.logger {}
@Controller
@SessionAttributes("devicesCount")
class InvitationController(
private val sdoService: SdoService,
private val storageService: StorageService
) {
companion object {
const val INVITATION_ID = "invitationId"
}
@GetMapping(params = ["step=3"])
fun getEnrolment(@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal): String {
logger.info { "Step 3. ${sdoUser.user.displayName} <${sdoUser.user.email}>"}
return "enroll"
}
@HxRequest
@GetMapping("/enroll/invite")
fun getEnrolment(@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal,
@SessionAttribute(value = OtpService.OTP_DEMO_KEY, required = false) demo: String?,
model: Model, session: HttpSession
):String {
storageService.upsert(sdoUser.user, "enroll")
if (demo == null) {
val invitationId = sdoService.createSingleInvitation(sdoUser.registrationId, sdoUser.user.id).invitation.id
val invitationUrl =
sdoService.getInvitation(sdoUser.registrationId, sdoUser.user.id, invitationId)!!.enrollmentUrl
session.setAttribute(INVITATION_ID, invitationId)
model.addAttribute("invitationLink", invitationUrl)
} else {
model.addAttribute("invitationLink", "https://doubleoctopus.com")
model.addAttribute("demo", demo)
}
return "enroll :: invite"
}
@GetMapping("/enroll/status")
fun getEnrolmentStatus(
@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal,
@SessionAttribute(name = "invitationId", required = false) invitationId: String?,
@SessionAttribute(name = OtpService.OTP_DEMO_KEY, required = false) demo: String?): String {
if (demo == null) {
requireNotNull(invitationId)
val invitation = sdoService.getInvitation(sdoUser.registrationId, sdoUser.user.id, invitationId)
if (invitation != null) {
logger.debug { "Pooling invitation $invitationId status: ${invitation.invitationStatus}" }
return "enroll :: waiting"
} else {
logger.debug { "Invitation $invitationId not found" }
storageService.upsert(sdoUser.user, "enrolled")
}
}
return "redirect:htmx:/?step=4"
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
import org.springframework.stereotype.Controller
import org.springframework.core.env.Environment
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.SessionAttributes
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
@Controller
@SessionAttributes("devicesCount")
class LoginController(
private val turnstileValidationService: TurnstileValidationService,
private val environment: Environment
) {
@GetMapping("/login")
fun getLogin(
model: Model,
@RequestParam(required = false) error: String?
): String {
model.addAttribute("turnstileSiteKey", turnstileValidationService.turnstileSitekey)
model.addAttribute("turnstileEnabled", !environment.activeProfiles.contains("devel"))
model.addAttribute("loginError", error != null)
return "login"
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
import com.redinterglobal.welcome.config.MonitorAuthenticationSuccessHandler
import com.redinterglobal.welcome.config.MonitorLoginHintAuthorizationRequestResolver
import com.redinterglobal.welcome.config.PendingMonitorLoginHint
import com.redinterglobal.welcome.config.WelcomeConfig
import com.redinterglobal.welcome.domain.EnrolledUser
import com.redinterglobal.welcome.domain.EnrolledUserRepo
import com.redinterglobal.welcome.service.EnrolledUserSseRegistry
import org.springframework.core.env.Environment
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.http.MediaType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.CookieValue
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import jakarta.servlet.http.HttpSession
import kotlin.collections.contains
@Controller
class MonitorController(
private val enrolledUserRepo: EnrolledUserRepo,
private val enrolledUserSseRegistry: EnrolledUserSseRegistry,
private val turnstileValidationService: TurnstileValidationService,
private val environment: Environment,
private val welcomeConfig: WelcomeConfig
) {
@GetMapping("/monitor/login")
fun login(
model: Model,
@RequestParam(required = false) error: String?,
@CookieValue(name = MonitorAuthenticationSuccessHandler.COOKIE_NAME, required = false) monitorEmail: String?
): String {
model.addAttribute("turnstileSiteKey", turnstileValidationService.turnstileSitekey)
model.addAttribute("turnstileEnabled", !environment.activeProfiles.contains("devel"))
model.addAttribute("loginError", error != null)
model.addAttribute("monitorEmail", monitorEmail)
return "monitor/login"
}
@PostMapping("/monitor/login/select")
fun handleLogin(
@RequestParam("email") email: String,
redirectAttributes: RedirectAttributes,
session: HttpSession
): String {
session.removeAttribute(MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE)
val domain = getDomain(email)
if (domain == null) {
redirectAttributes.addAttribute("error", "Invalid email")
return "redirect:htmx:/monitor/login"
}
val registrationId = welcomeConfig.idp[domain]
if (registrationId == null) {
redirectAttributes.addAttribute("error", "Invalid domain")
return "redirect:htmx:/monitor/login"
}
session.setAttribute(
MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE,
PendingMonitorLoginHint("${registrationId}-idP", email)
)
return "redirect:htmx:/oauth2/authorization/${registrationId}-idP"
}
private fun getDomain(username: String): String? {
val user = username.lowercase()
val at = user.lastIndexOf("@")
return if (at < 0 || at == user.length - 1) {
null
} else user.substring(at + 1)
}
@ResponseBody
@GetMapping("/monitor/users", produces = [MediaType.APPLICATION_JSON_VALUE])
fun getUserList(): List<EnrolledUser> = enrolledUserRepo.findAll().filterNotNull()
@GetMapping("/monitor")
fun getAdminPage(model: Model): String {
return "monitor/monitor"
}
@GetMapping("/monitor/sse", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamSse(@AuthenticationPrincipal principal: OAuth2User): SseEmitter {
val email = principal.getAttribute<String>("email") ?: principal.name
val domain = email.substringAfter("@")
return enrolledUserSseRegistry.subscribe(domain)
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.servlet.ModelAndView
import org.springframework.web.servlet.resource.NoResourceFoundException
@ControllerAdvice(annotations = [Controller::class])
class NotFoundExceptionHandler {
@ExceptionHandler(NoResourceFoundException::class)
fun handleNotFound(): ModelAndView = ModelAndView("error/404").apply {
status = HttpStatus.NOT_FOUND
}
}
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import com.redinterglobal.welcome.config.SdoAuthenticationPrincipal
import com.redinterglobal.welcome.service.IEmailService
import com.redinterglobal.welcome.service.OtpService
import com.redinterglobal.welcome.service.OtpService.Companion.OTP_DEMO_KEY
import com.redinterglobal.welcome.service.StorageService
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponse
import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxReswap
import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest
import jakarta.servlet.http.HttpSession
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.servlet.ModelAndView
import kotlin.time.Clock
private val logger = KotlinLogging.logger {}
@Controller
@RequestMapping("", "/")
class OtpController(
private val otpService: OtpService,
private val emailService: IEmailService,
private val storageService: StorageService
) {
companion object {
internal const val OTP_COUNT_KEY = "otpCount"
private const val MAX_OTP_FAILURES = 3
private val OTP_PATTERN = Regex("[0-9]{6}")
}
@GetMapping(params = ["step=2"])
fun otpPage(@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal): String {
storageService.upsert(sdoUser.user, "otp")
return "otp"
}
@GetMapping("/otp")
fun handleOtpTimer(model: Model,
session: HttpSession,
@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal
): String {
val existingOtp = otpService.getOtp(sdoUser.user.email!!)
if (existingOtp.otp != 0) {
val duration = (existingOtp.expiry - Clock.System.now())
model.addAttribute("otpExpirySeconds", duration.inWholeSeconds.coerceAtLeast(0))
} else {
otpService.generateOtp(sdoUser.user.email!!).run {
emailService.sendOtpEmail(sdoUser.user.email!!, sdoUser.user.displayName?:"", this)
}
session.removeAttribute(OTP_COUNT_KEY)
model.addAttribute("otpExpirySeconds", otpService.duration)
}
return "otp :: timer"
}
@HxRequest
@PostMapping("/otp")
fun handleOtpSubmit(
@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal,
@RequestParam(required = false) otp: String?,
session: HttpSession,
htmxResponse: HtmxResponse,
): Any {
val email = sdoUser.user.email!!
val failures = session.getAttribute(OTP_COUNT_KEY) as? Int ?: 0
if (failures >= MAX_OTP_FAILURES) {
throw OtpAttemptLimitException()
}
val submittedOtp = otp?.takeIf(OTP_PATTERN::matches)?.toIntOrNull()
val activeOtp = otpService.getOtp(email)
if (submittedOtp == OtpService.OTP_DEMO) {
logger.warn { "OTP demo used for email: $email" }
session.setAttribute(OTP_DEMO_KEY, OTP_DEMO_KEY)
return "redirect:htmx:/?step=3"
} else {
session.removeAttribute(OTP_DEMO_KEY)
}
if (activeOtp.expiry <= Clock.System.now()) {
htmxResponse.setReswap(HtmxReswap.none())
htmxResponse.addTrigger("otp-expired")
return ResponseEntity.status(HttpStatus.GONE).build<Void>()
}
if (submittedOtp == null || activeOtp.otp != submittedOtp) {
session.setAttribute(OTP_COUNT_KEY, failures + 1)
return failure(HttpStatus.UNPROCESSABLE_CONTENT, "Invalid OTP", htmxResponse)
}
session.removeAttribute(OTP_COUNT_KEY)
return "redirect:htmx:/?step=3"
}
private fun failure(
status: HttpStatus,
message: String,
htmxResponse: HtmxResponse,
): ResponseEntity<Void> {
htmxResponse.setReswap(HtmxReswap.none())
htmxResponse.addTrigger(
"flash",
mapOf("target" to "#flash", "level" to "warning", "message" to message),
)
return ResponseEntity.status(status).build()
}
@ExceptionHandler(OtpAttemptLimitException::class)
fun otpAttemptLimit(): ModelAndView = ModelAndView("otp :: otpLocked").apply {
status = HttpStatus.TOO_MANY_REQUESTS
}
@HxRequest
@PostMapping("/otp/resend")
fun resendOtp(
@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal,
model: Model,
session: HttpSession,
): String {
val email = sdoUser.user.email!!
val otp = otpService.generateOtp(email)
emailService.sendOtpEmail(email, sdoUser.user.displayName ?: "", otp)
session.removeAttribute(OTP_COUNT_KEY)
model.addAttribute("otpExpirySeconds", otpService.duration)
return "otp :: timer"
}
@HxRequest
@GetMapping("/otpExpired")
fun otpExpired() = "otp :: otpExpired"
@HxRequest
@GetMapping("/otpRestart")
fun otpRestart() = "redirect:htmx:/otp"
@HxRequest
@GetMapping("/otpAccepted")
fun otpAccepted() = "otp :: otpAccepted"
}
internal class OtpAttemptLimitException : RuntimeException()
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import com.redinterglobal.sdo.SdoClient
import com.redinterglobal.welcome.config.SdoAuthenticationPrincipal
import com.redinterglobal.welcome.service.StorageService
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
private val logger = KotlinLogging.logger {}
@Controller
class PushController(
private val sdoClient: SdoClient,
private val storageService: StorageService
) {
@GetMapping(params = ["step=4"])
fun getPushTest(@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal): String {
logger.info { "Step 4. ${sdoUser.user.displayName} <${sdoUser.user.email}>"}
storageService.upsert(sdoUser.user, "enrolled")
return "push"
}
@HxRequest
@GetMapping("/push/now")
fun pushNow(
@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal): String {
storageService.upsert(sdoUser.user, "push")
sdoClient.stateVerify(sdoUser.registrationId, sdoUser.user.id)
storageService.upsert(sdoUser.user, "tested")
return "redirect:htmx:/done"
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.controller
import com.redinterglobal.welcome.config.SdoAuthenticationPrincipal
import com.redinterglobal.welcome.config.WelcomeConfig
import com.redinterglobal.welcome.service.StorageService
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
private val logger = KotlinLogging.logger {}
@Controller
@RequestMapping("/", "")
class WelcomeController(
private val welcomeConfig: WelcomeConfig,
private val storageService: StorageService
) {
@GetMapping
fun showDownload(model: Model,
@AuthenticationPrincipal sdoUser: SdoAuthenticationPrincipal): String {
logger.info { "Step 1. ${sdoUser.user.displayName} <${sdoUser.user.email}>"}
model.addAttribute("apps", welcomeConfig.appStores)
model.addAttribute("shortLink", welcomeConfig.shortLink)
model.addAttribute("nextStep", "2")
storageService.upsert(sdoUser.user, "download")
return "download"
}
@GetMapping("/done")
fun done() = "done"
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.domain
import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash
import org.springframework.data.redis.core.index.Indexed
import org.springframework.data.repository.ListCrudRepository
import kotlin.time.Clock
@RedisHash(value = "enrolledUsers", timeToLive = 3600)
class EnrolledUser private constructor(
@Id
var email: String,
val name: String?,
val step: String,
@Indexed
val domain: String,
val added: Long
) {
constructor(email: String, name: String?, step: String) : this(email, name, step, email.substringAfter("@"),
Clock.System.now().epochSeconds
)
val duration: Long
get() = Clock.System.now().epochSeconds - added
}
interface EnrolledUserRepo: ListCrudRepository<EnrolledUser, String> {
fun findAllByDomain(domain: String): List<EnrolledUser>
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import com.redinterglobal.welcome.domain.EnrolledUser
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
@Service
class EnrolledUserListener(
private val enrolledUserSseRegistry: EnrolledUserSseRegistry
) {
@Async
@EventListener
fun onEnrolledUser(enrolledUser: EnrolledUser) {
enrolledUserSseRegistry.broadcast(enrolledUser)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import com.redinterglobal.welcome.domain.EnrolledUser
import org.springframework.stereotype.Service
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
@Service
class EnrolledUserSseRegistry {
private val emittersByDomain = ConcurrentHashMap<String, MutableSet<SseEmitter>>()
fun subscribe(domain: String): SseEmitter = register(domain, SseEmitter(0L))
fun broadcast(enrolledUser: EnrolledUser) {
emittersByDomain[enrolledUser.domain]?.toList()?.forEach { emitter ->
try {
emitter.send(
SseEmitter.event()
.name("enrolled")
.data(enrolledUser)
)
} catch (exception: IOException) {
remove(enrolledUser.domain, emitter)
emitter.completeWithError(exception)
} catch (exception: IllegalStateException) {
remove(enrolledUser.domain, emitter)
}
}
}
internal fun register(domain: String, emitter: SseEmitter): SseEmitter {
emittersByDomain.computeIfAbsent(domain) { ConcurrentHashMap.newKeySet() }.add(emitter)
val cleanup = { remove(domain, emitter) }
emitter.onCompletion(cleanup)
emitter.onTimeout(cleanup)
emitter.onError { cleanup() }
return emitter
}
private fun remove(domain: String, emitter: SseEmitter) {
emittersByDomain[domain]?.let { emitters ->
emitters.remove(emitter)
if (emitters.isEmpty()) {
emittersByDomain.remove(domain, emitters)
}
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import org.springframework.http.MediaType
data class Logo(
val mediaType: MediaType,
val media: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Logo
if (mediaType != other.mediaType) return false
if (!media.contentEquals(other.media)) return false
return true
}
override fun hashCode(): Int {
var result = mediaType.hashCode()
result = 31 * result + media.contentHashCode()
return result
}
}
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.beans.factory.annotation.Value
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context
private val logger = KotlinLogging.logger {}
data class EmailDetails (
val recipient: String,
val subject: String,
val msgBody: String,
)
interface IEmailService {
fun sendSimpleEmail(details: EmailDetails): Boolean
fun sendMailWithAttachment(details: EmailDetails): Boolean
fun sendOtpEmail(recipient: String, name: String, otp: Int): Boolean
}
@Service
@Profile("!devel")
class EmailService(
private val javaMailSender: JavaMailSender,
@Qualifier("emailTemplateEngine") private val emailTemplateEngine: TemplateEngine,
@Value($$"${spring.mail.sender}") private val sender: String,
): IEmailService {
override fun sendSimpleEmail(details: EmailDetails): Boolean {
logger.info { "Sending email to ${details.recipient}" }
try {
SimpleMailMessage().apply {
from = sender
setTo(details.recipient)
text = details.msgBody
subject = details.subject
}.run {
javaMailSender.send(this)
}
return true
}
catch (e: Exception) {
logger.error { "Failed to send email to ${details.recipient}: ${e.message}" }
return false
}
}
override fun sendMailWithAttachment(details: EmailDetails): Boolean {
val htmlContent = ""
val mimeMessage = javaMailSender.createMimeMessage()
MimeMessageHelper(mimeMessage, true, "UTF-8").apply {
setFrom(sender)
setTo(details.recipient)
setSubject(details.subject)
setText(htmlContent, true)
}
javaMailSender.send(mimeMessage)
return true
}
override fun sendOtpEmail(recipient: String, name: String, otp: Int): Boolean {
logger.info { "Sending OTP email to $recipient" }
return try {
val htmlContent = emailTemplateEngine.process(
"mail/otp",
Context().apply {
setVariable("name", name)
setVariable("otp", otp)
},
)
val mimeMessage = javaMailSender.createMimeMessage()
MimeMessageHelper(mimeMessage, false, "UTF-8").apply {
setFrom(sender)
setTo(recipient)
setSubject("Código de verificación")
setText(htmlContent, true)
}
javaMailSender.send(mimeMessage)
true
} catch (e: Exception) {
logger.error(e) { "Failed to send OTP email to $recipient" }
false
}
}
}
@Service
@Profile("devel")
class DummyEmailService : IEmailService {
override fun sendSimpleEmail(details: EmailDetails) = true
override fun sendMailWithAttachment(details: EmailDetails) = true
override fun sendOtpEmail(recipient: String, name: String, otp: Int): Boolean {
logger.info { "Development OTP for $recipient: $otp" }
return true
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import com.github.benmanes.caffeine.cache.Caffeine
import com.redinterglobal.welcome.config.WelcomeConfig
import org.springframework.stereotype.Service
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
data class Otp(
val otp: Int,
val expiry: Instant
)
@Service
class OtpService(
welcomeConfig: WelcomeConfig
) {
companion object {
const val OTP_DEMO = 123456
const val OTP_DEMO_KEY = "demo"
}
private val secureRandom = SecureRandom()
val duration = welcomeConfig.otpDuration
private val otpCache =
Caffeine.newBuilder()
.expireAfterWrite(duration.toLong(), TimeUnit.SECONDS)
.build<String, Otp> { Otp(0, Instant.DISTANT_PAST) }
private fun random() = 100_000 + secureRandom.nextInt(900_000)
fun generateOtp(key: String): Int {
var otp: Int
do { otp = random() } while (otp == OTP_DEMO)
otpCache.put(key, Otp(otp, Clock.System.now() + duration.seconds))
return otp
}
fun getOtp(key: String): Otp {
return otpCache.get(key)
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import com.github.benmanes.caffeine.cache.Caffeine
import com.redinterglobal.sdo.DIRECTORIES_EXPLORER_MEMBERS_FILTER
import com.redinterglobal.sdo.SdoClient
import com.redinterglobal.sdo.domain.Invitation
import com.redinterglobal.sdo.domain.InvitationRequest
import com.redinterglobal.sdo.domain.InvitationResult
import com.redinterglobal.sdo.domain.InvitationType
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.cache.annotation.Caching
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
import kotlin.io.encoding.Base64
private val logger = KotlinLogging.logger {}
@Service
class SdoService(
private val sdoClient: SdoClient
) {
val logoCache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.HOURS)
.build { registrationId: String ->
val base64img = sdoClient.getGeneralSettings(registrationId).logo
if (!base64img.startsWith("data:image/")) {
logger.warn { "Invalid logo format $registrationId" }
return@build null
}
val (header, media) = base64img.split(",", limit = 2)
val mediaType = header.substringAfter(":").substringBefore(";")
Logo(MediaType.valueOf(mediaType), Base64.decode(media))
}
@Caching
fun groupId(registrationId: String, groupName: String): Long? {
val groups = sdoClient.directoriesMembersSearch(
registrationId = registrationId,
search = groupName,
filter = DIRECTORIES_EXPLORER_MEMBERS_FILTER.GROUPS
).data
if (groups.isEmpty()) return null
return groups.singleOrNull{ it.displayName == groupName }?.id
}
fun createSingleInvitation(registrationId: String, userId: Long): InvitationResult {
sdoClient.getInvitations(registrationId, userId).filter { it.type == InvitationType.OCTOPUS }
.forEach { sdoClient.deleteUserInvitation(registrationId, userId, it.id) }
return sdoClient.postInvitation(registrationId, userId, InvitationRequest(InvitationType.OCTOPUS, false))
}
fun getInvitation(registrationId: String, userId: Long, invitationId: String): Invitation? {
return sdoClient.getInvitations(registrationId, userId).singleOrNull { it.id == invitationId }
}
fun logo(registrationId: String) = logoCache.get(registrationId)
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import com.redinterglobal.sdo.DIRECTORIES_EXPLORER_MEMBERS_FILTER
import com.redinterglobal.sdo.SdoClient
import com.redinterglobal.sdo.domain.User
import com.redinterglobal.welcome.config.WelcomeConfig
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
data class SdoAccount(
val email: String,
val registrationId: String,
val user: User
)
@Service
class SdoWelcomeService(
private val welcomeConfig: WelcomeConfig,
private val sdoClient: SdoClient,
private val sdoService: SdoService
) {
fun getSdoUser(email: String): SdoAccount {
val domain = email.substringAfter("@")
val server = welcomeConfig.sdoDomainServers[domain.lowercase()]!!
val registrationId = server.registrationId
val user = (if (server.group != null) {
sdoService.groupId(registrationId, server.group)?.let { groupId ->
sdoClient.getGroupMembers(registrationId, groupId, search = email)
}
} else {
sdoClient.directoriesMembersSearch(
registrationId = registrationId,
search = email,
filter = DIRECTORIES_EXPLORER_MEMBERS_FILTER.USERS
)
})?.data?.singleOrNull{ it.email.equals(email, ignoreCase = true) }?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"Yor email address is not entitled to use this service."
)
return SdoAccount(email, registrationId, user)
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.thymeleaf.TemplateEngine
import org.thymeleaf.spring6.SpringTemplateEngine
import org.thymeleaf.templatemode.TemplateMode
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
import org.thymeleaf.templateresolver.ITemplateResolver
@Configuration
class SpringMailConfig {
@Bean
fun emailTemplateEngine(): TemplateEngine = SpringTemplateEngine().apply {
addTemplateResolver(htmlTemplateResolver())
}
private fun htmlTemplateResolver(): ITemplateResolver {
return ClassLoaderTemplateResolver().apply {
order = 1
resolvablePatterns = mutableSetOf("mail/*")
prefix = ""
suffix = ".html"
templateMode = TemplateMode.HTML
characterEncoding = "UTF-8"
isCacheable = true
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
package com.redinterglobal.welcome.service
import com.redinterglobal.sdo.domain.User
import com.redinterglobal.welcome.domain.EnrolledUser
import com.redinterglobal.welcome.domain.EnrolledUserRepo
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
@Service
class StorageService(
private val enrolledUserRepo: EnrolledUserRepo,
private val eventPublisher: ApplicationEventPublisher
)
{
fun upsert(user: User, status: String) {
val enrolledUser = EnrolledUser(user.email!!, user.displayName, status)
enrolledUserRepo.save(enrolledUser)
eventPublisher.publishEvent(enrolledUser)
}
}
+11
View File
@@ -0,0 +1,11 @@
spring:
thymeleaf:
# Read templates from the working tree so edits are visible without an IDE
# resource copy and without restarting the application.
prefix: file:src/main/resources/templates/
cache: false
devtools:
restart:
# IntelliJ may still copy a changed template to the runtime classpath.
# Do not treat that resource-only change as a JVM restart trigger.
additional-exclude: templates/**
+39
View File
@@ -0,0 +1,39 @@
logging:
level:
com.redinterglobal.welcome: INFO
org.zalando.logbook: INFO
com.redinterglobal.thymepack: WARN
logbook:
format:
style: http
filter:
form-request-mode: PARAMETER
obfuscate:
json-body-fields:
- token
headers:
- token
- authorization
server:
forward-headers-strategy: framework
ds:
cf:
turnstile:
sitekey: ${cf.sitekey}
secret: ${cf.secret}
spring:
config:
import:
- optional:file:.local.yaml
messages:
fallback-to-system-locale: false
threads:
virtual:
enabled: true
+8
View File
@@ -0,0 +1,8 @@
________ __________________
___ __ \_____ _________________ ____________________ /__ ____/________________
__ /_/ / __ `/_ ___/_ ___/_ | /| / / __ \_ ___/ __ /__ /_ __ ___/ _ \ _ \
_ ____// /_/ /_(__ )_(__ )__ |/ |/ // /_/ / / / /_/ / _ __/ _ / / __/ __/
/_/ \__,_/ /____/ /____/ ____/|__/ \____//_/ \__,_/ /_/ /_/ \___/\___/
${application.title} ${application.version}
Powered by Spring Boot ${spring-boot.version}
+67
View File
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
.tick {
font-size:1.75rem; white-space:nowrap; font-family:arial,sans-serif
}
.tick-flip,.tick-text-inline {
font-size: 1.75em;
}
.tick-label {
margin-top:1em;font-size:1em;
}
.tick-char {
width:1.5em;
}
.tick-text-inline {
display:inline-block;text-align:center;min-width:1em;
}
.tick-text-inline+.tick-text-inline {
margin-left:-.325em;
}
.tick-group {
margin:0 .5em;text-align:center;
}
.tick-text-inline {
color: rgb(90, 93, 99) !important;
}
.tick-label {
color: rgb(90, 93, 99) !important;
}
.tick-flip-panel {
color: rgb(255, 255, 255) !important;
}
.tick-flip {
font-family: !important;
}
.tick-flip-panel-text-wrapper {
line-height: 1.45 !important;
}
.tick-flip-panel {
background-color: rgb(59, 61, 59) !important;
}
.tick-flip {
border-radius:0.12em !important;
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import '../styles/styles.scss';
import htmx from "htmx.org";
import 'htmx.org/dist/ext/hx-live.js'
import party from 'party-js';
window.party = party;
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import '../styles/styles.scss'
import { initQrCode } from './qrcode.js'
import htmx from 'htmx.org';
window.initQrCode = initQrCode
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import '../styles/styles.scss'
import htmx from 'htmx.org';
import { HotkeyManager } from '@tanstack/hotkeys';
import { initQrCode } from './qrcode.js'
HotkeyManager.getInstance().register(['s', 'd', 'o'], () => {
console.log('sdo!')
htmx.trigger(document.body, 'demo');
});
window.initQrCode = initQrCode;
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import '../styles/styles.scss'
import htmx from 'htmx.org';
window.htmx = htmx;
import Alpine from 'alpinejs';
Alpine.start();
import './turnstile-htmx.js';
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import '../styles/styles.scss';
import '../styles/datatables.scss';
import htmx from 'htmx.org';
import 'htmx.org/dist/ext/hx-sse.js'
import DataTable from 'datatables.net-dt';
import moment from 'moment';
window.htmx = htmx;
let table = new DataTable('#table', {
responsive: true,
ajax: {
url: '/monitor/users',
dataSrc: ''
},
columns: [
{ data: 'name' },
{ data: 'email' },
{
data: 'step',
render: function(data,type) {
if (type==='display') {
if (data==='download') {
return '<span class="badge badge-subtle theme-danger">Download</span>';
} else if (data==='otp') {
return '<span class="badge badge-subtle theme-warning">OTP</span>';
} else if (data==='enroll') {
return '<span class="badge theme-warning">Enroll</span>';
} else if (data==='enrolled') {
return '<span class="badge badge-subtle theme-success">Enrolled</span>';
} else if (data==='push') {
return '<span class="badge badge-subtle theme-success">Enrolled</span>'+
'<span class="badge badge-subtle theme-info">Notification</span>';
} else if (data==='tested') {
return '<span class="badge badge-subtle theme-success">Enrolled</span>'+
'<span class="badge badge-subtle theme-success">Tested</span>';
}
}
return data;
}
},
{
data: 'duration',
render: function(data,type) {
if (type==='display') {
return moment.duration(data, 'seconds').humanize();
}
return data;
}
}
]
});
htmx.on('htmx:sse:after:message', event => {
console.log('updated', event.detail);
table.ajax.reload();
})
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import '../styles/styles.scss';
import '@pqina/flip/dist/flip.min.css';
import '../css/tick.css';
import htmx from 'htmx.org';
import 'htmx.org/dist/ext/hx-live.js'
import * as bootstrap from 'bootstrap'
import Tick from '@pqina/flip';
import { rootSelector } from './util.js';
window.bootstrap = bootstrap;
document.body.addEventListener('htmx:after:swap', (evt) => {
rootSelector(evt, '.tick', (el) => {
const tick = Tick.DOM.create(el);
const offset = new Date();
const seconds = el.dataset.otpExpiry;
const deadline = new Date(offset.setMilliseconds(offset.getMilliseconds() + (seconds * 1000)));
const counter = Tick.count.down(deadline, {format: ['m', 's']});
counter.onupdate = function(value) {
tick.value = value;
}
counter.onended = function() {
htmx.trigger('#otp-expired', 'otp-expired');
}
})
rootSelector(evt, '.otp', (el) => {
el.otp = bootstrap.OtpInput.getOrCreateInstance(el);
});
});
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import { QRCodeJs } from '@qr-platform/qr-code.js';
function initQrCode(el) {
const { qrData,qrShape, qrImage, qrIsResponsive} = el.dataset;
if (!qrData) return;
const options = { data: qrData };
if (qrShape) options.shape = qrShape;
if (qrImage) options.image = qrImage;
options.dotsOptions = { type: 'rounded'}
if (qrIsResponsive !== undefined) {
options.isResponsive = qrIsResponsive === 'true';
}
const qrCode = new QRCodeJs(options);
qrCode.append(el, { clearContainer: true });
}
export { initQrCode };
+70
View File
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import Alpine from 'alpinejs';
Alpine.data('turnstileLogin', () => ({
init() {
this.render();
},
destroy() {
this.remove();
},
render() {
const widget = this.$refs.widget;
if (!widget || !window.turnstile || widget.hasChildNodes()) return;
window.turnstile.render(widget, {
sitekey: widget.dataset.sitekey,
size: widget.dataset.size || 'normal',
theme: widget.dataset.theme || 'auto',
callback: window[widget.dataset.callback] || undefined,
'error-callback': window[widget.dataset.errorCallback] || undefined,
});
},
remove() {
const widget = this.$refs.widget;
if (!widget || !window.turnstile) return;
try {
window.turnstile.remove(widget);
} catch {
// The widget may already have been removed by a prior swap.
}
},
removeIfSwapped(event) {
const target = event.detail.target;
if (target === this.$el || target?.contains(this.$refs.widget)) {
this.remove();
}
},
renderIfSwapped(event) {
const target = event.detail.target;
if (target === this.$el || target?.contains(this.$el)) {
this.$nextTick(() => this.render());
}
},
resetIfFailed(event) {
if (event.detail.xhr?.status < 400) return;
const widget = this.$refs.widget;
if (widget?.hasChildNodes() && window.turnstile) {
window.turnstile.reset(widget);
}
},
}));
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
function rootSelector(evt, selector, callback) {
const root = evt.detail?.target || evt.detail?.elt || document;
if (!root.querySelectorAll) return;
root.querySelectorAll(selector + ':not([data-initialized])').forEach(el => {
try {
callback(el);
el.dataset.initialized = 'true';
} catch {
console.warn('Failed to init element', el);
}
});
}
export { rootSelector }
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
import '../styles/styles.scss'
import htmx from 'htmx.org';
import { HotkeyManager } from '@tanstack/hotkeys'
import { initQrCode } from './qrcode.js'
// cheet('s d o', () => {
// console.log('sdo!');
//})
HotkeyManager.getInstance().register(['s', 'd', 'o'], () => {
console.log('sdo!')
});
window.initQrCode = initQrCode;
window.htmx = htmx;
+32
View File
@@ -0,0 +1,32 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:remove="all">Template for HTML OTP</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<p>
Aquí está su código de un uso
</p>
<h1 th:text="${otp}">:</h1>
<p>
Mensaje generado automáticamente, favor de no contestar.
</p>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
login.title=Start your passwordless journey.
login.description=Your email address has to be authorized by your administrator to access this service.
login.description2=We will guide you through the process of installing Double Octopus authenticator and test it to start your journey.
login.email=Corporate email address
login.start=Start
login.terms=By clicking Sign up, you agree the terms of use from your organization.
login.error=Your account is not authorized. Please contact your administrator.
error.404.page-title=Page not found
error.404.title=We couldn't find that page.
error.404.description=The address you entered does not lead to a page in this service.
error.404.description2=Return to sign in to continue your passwordless journey.
error.404.label=Page not found
error.404.action=Go to sign in
login.monitor.title=Follow you users passwordless journey.
login.monitor.description=
login.monitor.description2=
login.monitor.email=Administrator email
login.monitor.start=Start
login.monitor.terms=
login.monitor.error=Invalid account
download.title=Download Octopus Authenticator
download.description=You will need to install the Octopus Authenticator app from the App Store or Google Play into your mobile device.
download.description2=Your mobile device will be your main authentication device.
download.ready=I have downloaded the Octopus Authenticator App.
download.qr=Scan QR or open the following URL to download Octopus Authenticator in your mobile device
download.requirement=Requirement
otp.title=Verify your email
otp.description=You will need access to your corporate email.
otp.description2=We are sending a one time password, please type it in the field to the right.
otp.expired=One time password expired!
otp.restart=Restart
otp.locked=Too many incorrect codes. Please request a new code and try again.
otp.resend=Send a new code.
otpAccepted.title=Enrol your device
otpAccepted.description=Scan the QR with Octopus Authenticator
otpAccepted.description2=
push.title=Test it!
push.description=You will receive a push notification to test your enrolled device.
push.description2=This way you will be authenticated to your corporate services.
push.button=Test it!
done.title=It's alive
done.description=Your notifications are working.
done.description2=From now on you can forget dealing with passwords, just authorize with your mobile.
done.welcome=Welcome!
+51
View File
@@ -0,0 +1,51 @@
login.title=Inicie el camino a una operación sin contraseñas.
login.description=Su cuenta deberá de estar autorizada por su empresa para el uso de este servicio.
login.description2=Le guiaremos en la instalación y configuración del Double Octopus Authenticator.
login.email=Correo corporativo
login.start=Iniciar
login.terms=Al indicar 'Iniciar', acepta los Términos y Condiciones de uso
login.error=Su cuenta no está autorizada. Favor de contactar a su administrador.
error.404.page-title=Página no encontrada
error.404.title=No pudimos encontrar esa página.
error.404.description=La dirección ingresada no corresponde a una página de este servicio.
error.404.description2=Vuelva a iniciar sesión para continuar su camino sin contraseñas.
error.404.label=Página no encontrada
error.404.action=Ir a iniciar sesión
login.monitor.title=Siga el camino de sus usuarios.
login.monitor.description=
login.monitor.description2=
login.monitor.email=Correo del administrador
login.monitor.start=Iniciar
login.monitor.terms=
login.monitor.error=Cuenta inválida
download.title=Instale Octopus Authenticator
download.description=Deberá de instalar Octopus Authenticator descargándolo del App Store o Google Play desde su teléfono.
download.description2=Su teléfono será su autenticador principal.
download.ready=Ya descargué el App Octopus Authenticator
download.qr=Escanee el código QR o abra el siguiente URL para descargar el Octopus Authenticator en su teléfono móvil.
download.requirement=Requerimiento
push.title=¡Probemos!
push.description=Le vamos a enviar una notificación para que pruebe su dispositivo recientemente enrolado.
push.description2=En adelante esta será la forma de ingresar a sus servicios corporativos.
push.button=Probar ahora
done.title=¡Funciona!
done.description=Las notificaciones funcionan.
done.description2=A partir de este momento se puede olvidar de gestionar su contraseña, sólo debe de autorizar el acceso con su móvil.
done.welcome=¡Bienvenido!
otp.title=Verifique su correo
otp.description=Va a necesitar acceso a su correo corporativo.
otp.description2=Le vamos a enviar un código a su correo, favor de ingresarlo a continuación.
otp.expired=Expiró el código
otp.restart=Reenviar
otp.locked=Demasiados códigos incorrectos. Solicite un código nuevo e intente de nuevo.
otp.resend=Enviar un código nuevo
otpAccepted.title=Inscriba su dispositivo
otpAccepted.description=Abra la aplicación Octopus Authenticator y
otpAccepted.description2=escanee el QR para terminar su inscripción
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+46
View File
@@ -0,0 +1,46 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 175.6 223" width="175.6" height="223">
<path fill="#272D58" d="m87.7 0-54.9 31.7v63.4l54.9 31.7 54.9-31.7v-63.4zm34.5 83.3-34.5 19.9-34.5-19.9v-39.8l34.5-19.9 34.5 19.9z"/>
<path fill="#272D58" d="m133.3 142.4c-5.1-2.7-10.1-5.8-14.9-9.1-13.8-10.2-16.5-33.9-16.6-34.1l-5.2 0.6-3.2 0.2-5.6 3.2-5.4-3.1-5.7-0.3-5.2-0.5c0 0.2-2.9 24-16.6 34.1-4.8 3.3-9.8 6.3-14.9 9.1-17.8 10-40 22.4-40 46.2v33.8h17.1v-33.8c0-13.8 15.9-22.6 31.2-31.2l0.1-0.1c-6.3 9.2-9.6 20.2-9.4 31.3v33.8h17.2v-33.8c0-15.5 7.6-24 15.6-33 2.1-2.4 4.3-4.8 6.3-7.4v74.2h17.1v-74.3c2 2.6 4.2 5 6.3 7.4 8 9 15.6 17.5 15.6 33v33.8h17.1v-33.7c0.1-11.2-3.1-22.1-9.4-31.3l0.1 0.1c15.4 8.6 31.3 17.5 31.3 31.2v33.8h17.1v-33.8c0-23.9-22.2-36.3-40-46.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 814 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 175.6 223" width="175.6" height="223">
<rect width="175.6" height="223" fill="#07172F"/>
<path fill="#1B2E5C" d="m87.7 0-54.9 31.7v63.4l54.9 31.7 54.9-31.7v-63.4zm34.5 83.3-34.5 19.9-34.5-19.9v-39.8l34.5-19.9 34.5 19.9z"/>
<path fill="#1B2E5C" d="m133.3 142.4c-5.1-2.7-10.1-5.8-14.9-9.1-13.8-10.2-16.5-33.9-16.6-34.1l-5.2 0.6-3.2 0.2-5.6 3.2-5.4-3.1-5.7-0.3-5.2-0.5c0 0.2-2.9 24-16.6 34.1-4.8 3.3-9.8 6.3-14.9 9.1-17.8 10-40 22.4-40 46.2v33.8h17.1v-33.8c0-13.8 15.9-22.6 31.2-31.2l0.1-0.1c-6.3 9.2-9.6 20.2-9.4 31.3v33.8h17.2v-33.8c0-15.5 7.6-24 15.6-33 2.1-2.4 4.3-4.8 6.3-7.4v74.2h17.1v-74.3c2 2.6 4.2 5 6.3 7.4 8 9 15.6 17.5 15.6 33v33.8h17.1v-33.7c0.1-11.2-3.1-22.1-9.4-31.3l0.1 0.1c15.4 8.6 31.3 17.5 31.3 31.2v33.8h17.1v-33.8c0-23.9-22.2-36.3-40-46.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 866 B

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" width="493.2" height="223" version="1.1" viewBox="0 0 493.20001 223.00001" xml:space="preserve"><metadata></metadata><style type="text/css"> .st0{fill:#408CFF;} </style><title>Logo_SDO_D_Converted_Transparent</title><path d="m235.4 59.3c0 5.1-1.2 9.1-3.6 12s-6.4 4.4-11.9 4.4c-5.2 0.1-10.4-1-15.2-3l1.5-5c4.3 1.5 8.8 2.3 13.3 2.3 3.2 0 5.5-0.8 7-2.4s2.2-4 2.2-7.2c0.1-2.5-0.3-5-1.2-7.4-0.8-1.7-2.5-2.7-5.1-3.1l-8-1.1c-3.8-0.6-6.3-2.1-7.6-4.6s-1.9-6.2-1.9-11.3c0-5 1.3-8.9 3.9-11.7s6.2-4.2 10.9-4.2 9.5 0.9 13.9 2.6l-1.5 4.9c-3.6-1.1-7.3-1.6-11-1.7-3.3 0-5.7 0.7-7.2 2s-2.3 3.8-2.3 7.4 0.4 6.2 1.2 7.8 2.4 2.5 4.7 2.8l7.6 1.1c3 0.3 5.8 1.8 7.7 4.3 1.7 2.3 2.6 6 2.6 11.1z"></path><path d="m246.7 74.7v-56.7h27l-0.8 5.7h-19.2v19.8h17.6v5.7h-17.6v19.9h20.8v5.7z"></path><path d="m290 19.1c1.5-0.6 3.1-1 4.7-1.4 2.5-0.5 5-0.7 7.6-0.6 3.2 0 6.4 0.5 9.5 1.5l-1.2 5.3c-2.6-0.6-5.3-1-8-1-2 0-3.9 0.1-5.9 0.3-1.1 0.1-2.2 0.5-3.1 1.1-0.7 0.4-1.3 1-1.9 1.5-0.5 0.7-0.9 1.4-1 2.3-0.3 1.9-0.4 3.7-0.3 5.6v25.5c0 4.3 0.4 6.9 1.3 7.9 1.2 1.3 2.8 2.2 4.5 2.6 2.1 0.3 4.1 0.4 6.2 0.4 3-0.1 6-0.5 8.8-1.3l1.5 4.8c-3.7 1.5-7.6 2.3-11.6 2.3-3.9 0-6.6-0.3-8.1-0.8s-2.7-1-3.6-1.4-1.7-1.1-2.3-1.9c-0.7-0.9-1.2-1.7-1.7-2.4-0.6-1-0.9-2-1.1-3.2-0.3-1.5-0.5-2.7-0.7-3.7-0.2-1.5-0.3-3.1-0.3-4.7-0.1-4.1-0.2-8-0.2-11.6s0-6.3 0.1-8.2c0.1-1.8 0.2-4 0.4-6.6 0-1.9 0.4-3.8 1-5.6 0.5-1.1 1.1-2.4 1.9-3.8 1-1.4 2.1-2.4 3.5-2.9z"></path><path d="m335.2 53h-5.3v21.7h-7v-56.7h12.3c6.5 0 11.1 1.3 13.9 3.9s4.1 7.1 4.1 13.6c0 4.4-0.6 7.9-1.9 10.5-1.2 2.5-3.4 4.5-6 5.5l9.3 23.2h-7.1l-8.9-21.8c-0.8 0.1-1.9 0.1-3.4 0.1zm11-15.7v-3.6c0-1.5 0-2.6-0.1-3.4-0.1-0.9-0.3-1.8-0.6-2.6-0.3-0.9-0.9-1.7-1.7-2.3-1.9-1.1-4.7-1.7-8.5-1.7h-5.3v23.5h5.3c3.8 0 6.6-0.6 8.5-1.7 0.8-0.5 1.4-1.3 1.7-2.3 0.3-0.8 0.5-1.7 0.6-2.6 0.1-0.6 0.1-1.8 0.1-3.3z"></path><path d="m366.3 74.7v-56.7h27l-0.8 5.7h-19.2v19.8h17.6v5.7h-17.6v19.9h20.8v5.7z"></path><path d="m397.7 23.7v-5.7h30.8v5.7h-11.9v51h-7v-51z"></path><g fill="#408cff"><path class="st0" d="m207 91.7h16.2c3.9-0.1 7.7 0.5 11.3 1.8 1.4 0.5 2.5 1.5 3.2 2.7s1.4 2.4 1.9 3.6c0.6 1.7 1 3.5 1 5.3 0.3 4 0.5 8.8 0.5 14.5 0 5.6-0.1 9.6-0.2 11.9-0.1 2.6-0.5 5.1-1 7.6-0.6 2.8-1.5 4.7-2.8 5.6-3.2 2.4-7.9 3.6-14 3.6h-16.2zm16.3 5.8h-9.3v44.9h9.3c3.7 0 6.6-0.6 8.5-1.9 1.3-0.9 2.1-2.6 2.3-5.1 0.1-0.8 0.1-2 0.1-3.6v-23.6c0-1.6 0-2.8-0.1-3.6-0.1-1-0.3-1.9-0.6-2.8-0.3-1-0.8-1.8-1.7-2.4-1.9-1.3-4.8-1.9-8.5-1.9z"></path><path class="st0" d="m282.6 147.4c-1.6 0.6-3.2 1-4.8 1.3-2 0.4-4 0.6-6 0.6-2.2 0-4-0.1-5.4-0.2-1.3-0.1-2.6-0.4-3.9-0.8-1.1-0.4-2.1-0.8-3-1.2s-1.7-1.1-2.3-1.9c-0.7-0.9-1.2-1.7-1.7-2.4-0.6-1-0.9-2-1.1-3.2-0.3-1.5-0.5-2.7-0.7-3.7-0.2-1.5-0.3-3.1-0.3-4.7-0.1-4.1-0.2-8-0.2-11.6s0-6.3 0.1-8.2c0.1-1.8 0.2-4 0.4-6.6 0-1.9 0.4-3.8 1-5.5 0.5-1.1 1.1-2.4 1.9-3.8 0.7-1.3 1.8-2.4 3.2-3 3.5-1.3 7.2-2 10.9-1.9 3.1-0.1 6.2 0.3 9.1 1.2 1.9 0.6 3.7 1.6 5.3 2.8 1.3 1.1 2.2 3.1 2.8 6 0.5 2.6 0.9 5.3 1 7.9 0.1 2.4 0.2 5.8 0.2 10.4s0 7.8-0.1 9.6-0.2 4-0.4 6.6c0 1.9-0.4 3.8-1 5.5-0.6 1.3-1.2 2.5-1.9 3.8-0.6 1.4-1.7 2.5-3.1 3zm-0.4-14.7v-25.4c0-4.3-0.5-6.9-1.4-7.9-1.1-1.3-2.7-2.2-4.4-2.6-1.7-0.3-3.4-0.4-5.1-0.4-1.5 0-3.1 0.1-4.6 0.3-1.1 0.1-2.2 0.5-3.1 1.1-0.7 0.4-1.3 1-1.9 1.5-0.5 0.7-0.9 1.4-1 2.3-0.3 1.9-0.4 3.7-0.3 5.6v25.4c0 4.3 0.4 6.9 1.3 7.9 1.2 1.3 2.7 2.3 4.5 2.6 1.7 0.3 3.4 0.4 5.1 0.4 1.5 0 3.1-0.1 4.6-0.3 1.1-0.1 2.1-0.5 3.1-1.1 0.7-0.4 1.3-1 1.9-1.5 0.5-0.6 0.9-1.4 0.9-2.3 0.3-1.8 0.4-3.7 0.4-5.6z"></path><path class="st0" d="m330.9 132.7v-41.1h7v28.4c0 11.6-0.5 18.4-1.5 20.6-0.5 1.3-1.2 2.6-1.9 3.8-0.7 1.3-1.8 2.3-3.2 2.9-1.5 0.6-3.1 1-4.7 1.3-2.2 0.4-4.4 0.6-6.6 0.6s-4.4-0.2-6.6-0.6c-1.6-0.3-3.2-0.8-4.7-1.3-1.4-0.6-2.5-1.6-3.2-2.9-0.7-1.2-1.3-2.5-1.9-3.8-0.6-1.8-0.9-3.6-1-5.5-0.3-3.2-0.4-8.3-0.4-15.1v-28.4h7v41c0 1.6 0 2.8 0.1 3.6 0.1 1 0.3 1.9 0.6 2.8 0.3 1 0.8 1.8 1.7 2.4 1 0.6 2.1 1 3.3 1.3 1.7 0.4 3.5 0.6 5.3 0.6s3.5-0.2 5.3-0.6c1.1-0.3 2.2-0.7 3.2-1.3 0.8-0.6 1.4-1.4 1.7-2.4 0.3-0.9 0.5-1.8 0.6-2.8-0.1-0.7-0.1-1.9-0.1-3.5z"></path><path class="st0" d="m352.1 91.7h13.4c5.9 0 10 1.2 12.4 3.7s3.6 6.7 3.6 12.8c0 6-1.9 10-5.7 11.8 2.3 0.7 4.3 2.3 5.5 4.3s1.7 4.8 1.7 8.3c0 5.6-1.3 9.7-3.9 12.1s-7 3.7-13.3 3.7h-13.8v-56.7zm22.3 17.5v-3.2c0-3.9-0.6-6.3-1.9-7.1-1.5-1-3.8-1.5-7-1.5h-6.4v20.4h5.6c3.5 0 6.1-0.5 7.7-1.5 1.2-0.7 1.9-2.1 2-4.2v-2.9zm1.6 24.9v-3.2c0-3.3-0.4-5.4-1.3-6.1-1.3-1.1-2.9-1.8-4.5-2.1-1.7-0.2-3.3-0.3-5-0.3h-6.1v20.2h7c3.6 0 6.3-0.5 7.9-1.5 1.2-0.7 1.9-2 2-4v-3z"></path><path class="st0" d="m396.4 148.3v-56.7h7v51h18.3v5.7z"></path><path class="st0" d="m430.6 148.3v-56.7h27l-0.8 5.7h-19.2v19.8h17.6v5.7h-17.6v19.9h20.8v5.7z"></path><path class="st0" d="m235.1 221c-1.6 0.6-3.2 1-4.8 1.3-2 0.4-4 0.6-6 0.6-2.2 0-4-0.1-5.4-0.2-1.3-0.1-2.6-0.4-3.9-0.8-1.1-0.4-2.1-0.8-3-1.2s-1.7-1.1-2.3-1.9c-0.7-0.9-1.2-1.7-1.7-2.4-0.6-1-0.9-2-1.1-3.Line truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 175.6 223" width="175.6" height="223">
<path fill="#B8C5FB" d="m87.7 0-54.9 31.7v63.4l54.9 31.7 54.9-31.7v-63.4zm34.5 83.3-34.5 19.9-34.5-19.9v-39.8l34.5-19.9 34.5 19.9z"/>
<path fill="#B8C5FB" d="m133.3 142.4c-5.1-2.7-10.1-5.8-14.9-9.1-13.8-10.2-16.5-33.9-16.6-34.1l-5.2 0.6-3.2 0.2-5.6 3.2-5.4-3.1-5.7-0.3-5.2-0.5c0 0.2-2.9 24-16.6 34.1-4.8 3.3-9.8 6.3-14.9 9.1-17.8 10-40 22.4-40 46.2v33.8h17.1v-33.8c0-13.8 15.9-22.6 31.2-31.2l0.1-0.1c-6.3 9.2-9.6 20.2-9.4 31.3v33.8h17.2v-33.8c0-15.5 7.6-24 15.6-33 2.1-2.4 4.3-4.8 6.3-7.4v74.2h17.1v-74.3c2 2.6 4.2 5 6.3 7.4 8 9 15.6 17.5 15.6 33v33.8h17.1v-33.7c0.1-11.2-3.1-22.1-9.4-31.3l0.1 0.1c15.4 8.6 31.3 17.5 31.3 31.2v33.8h17.1v-33.8c0-23.9-22.2-36.3-40-46.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,18 @@
ou{
"name": "redINTER",
"short_name": "redINTER",
"icons": [
{
"src": "/favicon/favicon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon/favicon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
/*!
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
@use "datatables.net-dt";
+130
View File
@@ -0,0 +1,130 @@
/*!
* Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
*
* This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
* proprietary and confidential software.
*
* Reproduction, modification, distribution, disclosure, or use of this
* source code without the copyright holder's prior written authorization
* is prohibited, except as permitted by applicable law or a valid
* license agreement.
*/
@use 'bootstrap/scss/bootstrap';
:root {
--bs-body-font-family: "Geist", system-ui, sans-serif;
--bs-font-mono: "Geist Mono", ui-monospace, monospace;
}
html,
body {
min-height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.layout-content {
display: flex;
flex: 1 0 auto;
align-items: center;
}
.layout-content > .container {
width: 100%;
}
.customer-logo-panel {
display: flex;
align-items: center;
justify-content: center;
min-height: 7.5rem;
max-width: 32rem;
margin: 0 auto 0.75rem;
padding: 1.5rem 2rem;
background: white;
border: 1px solid rgba(black, 0.08);
border-radius: var(--bs-border-radius-lg);
box-shadow: 0 0.5rem 1.5rem rgba(black, 0.12);
}
.customer-logo-header + section > .container {
padding-top: 0 !important;
}
.customer-logo {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 6rem;
object-fit: contain;
}
.store-downloads {
border-collapse: separate;
border-spacing: 0 0.75rem;
}
.store-downloads td {
padding: 0.25rem 0.5rem;
vertical-align: middle;
}
.store-downloads img {
display: block;
width: auto;
max-width: 8rem;
height: 2.5rem;
}
.enrollment-carousel-image-frame {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 4 / 3;
overflow: hidden;
background: white;
border-radius: var(--bs-border-radius-lg);
}
.enrollment-carousel-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.otp {
--otp-size: 3.5rem;
--otp-font-size: 1.25rem;
--otp-slot-border-width: 2px;
}
#otp-pane {
position: relative;
}
#flash {
position: absolute;
top: calc(100% + 0.75rem);
right: 0;
left: 0;
z-index: 1;
margin: 0;
}
@media (max-width: 575.98px) {
.customer-logo-panel {
min-height: 6rem;
padding: 1rem 1.25rem;
}
.customer-logo {
max-height: 4.5rem;
}
}
+36
View File
@@ -0,0 +1,36 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html(entrypoint='done')}"
xmlns:hx-on="http://www.w3.org/1999/xhtml">
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div class="row align-items-center lg:g-5 py-5">
<div layout:insert="~{fragments/panel::left(#{done.title},#{done.description},#{done.description2})}"
class="lg:col-7 text-center lg:text-start">
</div>
<div class="md:col-10 mx-auto lg:col-5">
<h1 th:text="#{done.welcome}">Welcome</h1>
<img src="/img/sdo-logo.svg" alt="Secret Double Octopus"
hx-on:load="window.party.confetti(this)"
onmousedown="window.party.confetti(this)"
>
</div>
</div>
</div>
</section>
</html>
@@ -0,0 +1,78 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html(entrypoint='download')}" xmlns:hx-on="http://www.w3.org/1999/xhtml">
<head>
<title>Download</title>
</head>
<body>
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div class="row align-items-center lg:g-5 py-5">
<div layout:insert="~{fragments/panel::left(#{download.title},#{download.description},#{download.description2})}"
class="lg:col-7 text-center lg:text-start">
<div layout:fragment="left-content">
<a th:href="@{/(step=${nextStep})}"
type="button" class="theme-primary btn-solid btn-styled btn-lg">
<img th:src="@{/img/sdo.svg}" class="bi me-1" width="16" height="16" alt="SDO">
<span th:text="#{download.ready}"></span>
</a>
</div>
</div>
<div class="md:col-10 mx-auto lg:col-5">
<div class="container d-flex flex-column align-items-center text-center">
<table class="store-downloads mb-3" aria-label="Octopus Authenticator downloads">
<thead>
<tr>
<td></td>
<td th:text="#{download.requirement}">Requirement</td>
</tr>
</thead>
<tbody>
<tr th:each="app: ${apps}">
<td>
<a th:href="${app.link}" th:aria-label="|Download for ${app.os}|" target="_target">
<img th:src="@{${app.logo}}" th:alt="|Download for ${app.os}|">
</a>
</td>
<td th:text="${app.version}">8</td>
</tr>
</tbody>
</table>
<div class="mb-3 m-3" th:unless="${shortLink == null}">
<figure class="figure" style="width: 240px;">
<div
id="qr"
aria-label="QR code to download the SDO Authenticator"
class="d-block mx-auto"
th:data-qr-data="${shortLink}"
data-qr-shape="circle"
th:data-qr-image="@{/img/sdo-blue.svg}"
data-qr-is-responsive="true"
hx-on:load="initQrCode(this)">
</div>
<figcaption class="figure-caption text-wrap" th:text="#{download.qr}">Scan QR to download the SDO Authenticator in your mobile device</figcaption>
</figure>
<h3 th:text="${shortLink}"></h3>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
+85
View File
@@ -0,0 +1,85 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:hx="http://www.w3.org/1999/xhtml"
xmlns:hx-on="http://www.w3.org/1999/xhtml"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html(entrypoint='enroll')}"
>
<head>
<title>Enroll</title>
</head>
<body>
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div class="row align-items-center lg:g-5 py-5">
<div layout:insert="~{fragments/panel::left(#{otpAccepted.title},#{otpAccepted.description},#{otpAccepted.description2})}"
class="lg:col-7 text-center lg:text-start">
</div>
<div class="md:col-10 mx-auto lg:col-5">
<div class="container d-flex flex-column align-items-center text-center">
<div class="md:col-10 mx-auto lg:col-5 position-relative"
hx:get="@{/enroll/invite}"
hx-swap="outerHTML"
hx-trigger="load">
</div>
</div>
</div>
</div>
</div>
</section>
</body>
<section th:fragment="invite">
<div aria-label="Invitation to enroll to Octopus Authenticator"
class="width: 240px; d-block mx-auto"
th:data-qr-data="${invitationLink}"
th:data-qr-image="@{/img/sdo-blue.svg}"
hx-on:load="window.initQrCode(this)"></div>
<!--
<div class="tick d-flex justify-content-center"
th:data-otp-expiry="180"
data-credits="false">
<div data-repeat="true"
data-layout="horizontal"
data-transform="preset(m,s) -> delay">
<div class="tick-group">
<div data-key="value" data-repeat="true" data-transform="pad(00) -> split -> delay">
<span data-view="flip"></span>
</div>
</div>
</div>
</div>
-->
<div th:unless="${demo != null}"
hx:get="@{/enroll/status}"
hx-trigger="every 3s [document.visibilityState === 'visible']">
</div>
<div th:if="${demo != null}"
hx:get="@{/enroll/status}"
hx-trigger="demo from:body">
</div>
</section>
<section th:fragment="waiting">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</section>
@@ -0,0 +1,42 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html}">
<head>
<title th:text="#{error.404.page-title}">Page not found</title>
</head>
<body>
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div class="row align-items-center lg:g-5 py-5">
<div layout:insert="~{fragments/panel::left(#{error.404.title},#{error.404.description},#{error.404.description2})}"
class="lg:col-7 text-center lg:text-start"></div>
<div class="md:col-10 mx-auto lg:col-5">
<div class="p-4 p-md-5 border rounded-3 bg-body-tertiary bg-opacity-75 text-center">
<p class="display-1 fw-bold text-primary mb-3" aria-hidden="true">404</p>
<p class="fs-5 mb-4" th:text="#{error.404.label}">Page not found</p>
<a class="w-100 btn btn-lg btn-primary" th:href="@{/login}" th:text="#{error.404.action}">
Go to sign in
</a>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
@@ -0,0 +1,30 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
>
<body>
<div layout:fragment="left(title, d1, d2)">
<h1 class="fs-5xl fw-bold lh-1 fg-emphasis-inverse mb-3" th:text="${title}">Title</h1>
<p class="lg:col-10 fs-xl" th:text="${d1}">Description 1</p>
<p class="lg:col-10 fs-xl" th:text="${d2}">Description 2</p>
<div layout:fragment="left-content">
</div>
</div>
</body>
+60
View File
@@ -0,0 +1,60 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:webpack="http://redinterglobal.com/thymeleaf/webpack">
<head>
<link webpack:entrypoint="${entrypoint}" th:unless="${entrypoint} == null">
<link webpack:entrypoint="welcome" th:if="${entrypoint} == null">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<title layout:title-pattern="$LAYOUT_TITLE - $CONTENT_TITLE">Passwordfree</title>
<link rel="icon" type="image/x-icon" th:href="@{/favicon/favicon.ico}">
<link rel="icon" type="image/png" sizes="16x16" th:href="@{/favicon/favicon/favicon-16x16.png}">
<link rel="icon" type="image/png" sizes="32x32" th:href="@{/favicon/favicon-32x32.png}">
<link rel="apple-touch-icon" sizes="180x180" th:href="@{/favicon/apple-touch-icon.png}">
<link rel="manifest" th:href="@{/site.webmanifest}">
<style>
body {
background: url("[(@{/img/sdo.svg})]");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
}
@media (prefers-color-scheme: dark) {
html body {
background-color: #07172F;
background-image: url("[(@{/img/sdo-dark.svg})]");
}
}
</style>
</head>
<body>
<header class="customer-logo-header" sec:authorize="isAuthenticated()">
<div class="container col-xl-10 col-xxl-8 px-4 pt-5">
<div class="customer-logo-panel">
<img th:src="@{/logo}" class="customer-logo" alt="Customer logo" sec:authorize="hasRole('ROLE_USER')">
<img th:src="@{/monitor/logo}" class="customer-logo" alt="Customer logo" sec:authorize="hasRole('ROLE_MONITOR')">
</div>
</div>
</header>
<section class="layout-content" layout:fragment="content">
</section>
</body>
+94
View File
@@ -0,0 +1,94 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html(entrypoint='login-page')}"
>
<head>
<title>Login</title>
<th:block th:if="${turnstileEnabled}">
<link rel="preconnect" href="https://challenges.cloudflare.com"/>
<script>
window.turnstileHtmxOnLoad = function () {
window.dispatchEvent(new Event('turnstile:ready'));
};
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&amp;onload=turnstileHtmxOnLoad" async defer></script>
</th:block>
</head>
<body>
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div class="row align-items-center lg:g-5 py-5">
<div layout:insert="~{fragments/panel::left(#{login.title},#{login.description},#{login.description2})}"
class="lg:col-7 text-center lg:text-start"></div>
<div class="md:col-10 mx-auto lg:col-5 position-relative">
<form id="login-form"
class="p-4 p-md-5 border rounded-3 bg-body-tertiary bg-opacity-75"
hx:post="@{/login}"
hx-swap="innerHTML"
hx-indicator="#spinner"
x-data="turnstileLogin"
x-on:turnstile:ready.window="render()"
x-on:before-swap.camel="removeIfSwapped($event)"
x-on:after-swap.camel="renderIfSwapped($event)"
x-on:after-request.camel="resetIfFailed($event)"
hx-status:401="swap:none"
>
<div class="form-floating mb-3">
<label for="floatingInput" th:text="#{login.email}">Corporate email address</label>
<input
type="email"
class="form-control"
id="floatingInput"
name="email"
placeholder="name@example.com"/>
</div>
<button class="w-100 btn btn-lg btn-primary" type="submit">
<span id="spinner" class="htmx-indicator">
<div class="spinner-border spinner-border-sm" aria-hidden="true" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</span>
<span th:text="#{login.start}"></span>
</button>
<small class="text-body-secondary" th:text="#{login.terms}">
>By clicking Sign up, you agree to the terms of use.</small>
<th:block th:if="${turnstileEnabled}">
<hr class="my-4" />
<div class="form-floating mb-3">
<div x-ref="widget" th:data-sitekey="${turnstileSiteKey}" data-size="normal"></div>
</div>
</th:block>
</form>
<div id="flash"
th:hidden="${loginError != true}"
class="alert flash theme-warning d-flex align-items-center"
role="alert"
hx-on="flash -> clearTimeout(this._flashTimeout);
this.textContent = message;
this.hidden = !this.textContent;
if (!this.hidden) this._flashTimeout = setTimeout(() => { this.textContent = ''; this.hidden = true; }, 3000)"
>
<span th:if="${loginError==true}" th:text="#{login.error}">Your account is not authorized. Please contact your administrator.</span>
</div>
</div>
</div>
</div>
</section>
</body>
@@ -0,0 +1,94 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html(entrypoint='login-page')}">
<head>
<title>Login</title>
<th:block th:if="${turnstileEnabled}">
<link rel="preconnect" href="https://challenges.cloudflare.com"/>
<script>
window.turnstileHtmxOnLoad = function () {
window.dispatchEvent(new Event('turnstile:ready'));
};
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&amp;onload=turnstileHtmxOnLoad" async defer></script>
</th:block>
</head>
<body>
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div class="row align-items-center lg:g-5 py-5">
<div layout:insert="~{fragments/panel::left(#{login.monitor.title},#{login.monitor.description},#{login.monitor.description2})}"
class="lg:col-7 text-center lg:text-start"></div>
<div class="md:col-10 mx-auto lg:col-5 position-relative">
<form id="login-form"
class="p-4 p-md-5 border rounded-3 bg-body-tertiary bg-opacity-75"
hx:post="@{/monitor/login/select}"
hx-swap="innerHTML"
hx-indicator="#spinner"
x-data="turnstileLogin"
x-on:turnstile:ready.window="render()"
x-on:before-swap.camel="removeIfSwapped($event)"
x-on:after-swap.camel="renderIfSwapped($event)"
x-on:after-request.camel="resetIfFailed($event)"
hx-status:401="swap:none"
>
<div class="form-floating mb-3">
<label for="floatingInput" th:text="#{login.monitor.email}">Corporate email address</label>
<input
type="email"
class="form-control"
id="floatingInput"
name="email"
placeholder="name@example.com"
th:value="${monitorEmail}"/>
</div>
<button class="w-100 btn btn-lg btn-primary" type="submit">
<span id="spinner" class="htmx-indicator">
<div class="spinner-border spinner-border-sm" aria-hidden="true" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</span>
<span th:text="#{login.monitor.start}"></span>
</button>
<small class="text-body-secondary" th:text="#{login.monitor.terms}">
>By clicking Sign up, you agree to the terms of use.</small>
<th:block th:if="${turnstileEnabled}">
<hr class="my-4" />
<div class="form-floating mb-3">
<div x-ref="widget" th:data-sitekey="${turnstileSiteKey}" data-size="normal"></div>
</div>
</th:block>
</form>
<div id="flash"
th:hidden="${loginError != true}"
class="alert flash theme-warning d-flex align-items-center"
role="alert"
hx-on="flash -> clearTimeout(this._flashTimeout);
this.textContent = message;
this.hidden = !this.textContent;
if (!this.hidden) this._flashTimeout = setTimeout(() => { this.textContent = ''; this.hidden = true; }, 3000)"
>
<span th:if="${loginError==true}" th:text="#{login.monitor.error}">Your account is not authorized. Please contact your administrator.</span>
</div>
</div>
</div>
</div>
</section>
</body>
@@ -0,0 +1,53 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html(entrypoint='monitor')}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<head>
<title>Monitor</title>
</head>
<body>
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5 align-self-start"
hx-sse:connect="/monitor/sse"
hx-on:enrolled="
console.log('enrolled', event.detail.data);
htmx.trigger(document.body, 'updated');
"
>
<div id="data">
<table id="table" class="table" th:section="list">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Step</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html(entrypoint='otp')}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<head>
<title>OTP Verification</title>
</head>
<section class="layout-content" layout:fragment="content">
<div id="otp-expired" hx-trigger="otp-expired"
hx:get="@{/otpExpired}"
hx-target="#otp-pane"
hx-swap="outerHTML"></div>
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div id="pane-controller" class="row align-items-center lg:g-5 py-5"
hx:get="@{/otpAccepted}"
hx-trigger="otp-accepted"
hx-target="#instructions-pane">
<div id="instructions-pane" layout:insert="~{fragments/panel::left(#{otp.title},#{otp.description},#{otp.description2})}"
class="lg:col-7 text-center lg:text-start">
</div>
<div id="otp-pane" class="md:col-10 mx-auto lg:col-5 position-relative"
hx:get="@{/otp}"
hx-swap="outerHTML"
hx-trigger="load">
</div>
</div>
</div>
</section>
<div th:fragment="timer" id="otp-pane" class="md:col-10 mx-auto lg:col-5 position-relative">
<div id="timer">
<div class="tick d-flex justify-content-center"
th:data-otp-expiry="${otpExpirySeconds}"
data-credits="false">
<div data-repeat="true"
data-layout="horizontal"
data-transform="preset(m,s) -> delay">
<div class="tick-group">
<div data-key="value" data-repeat="true" data-transform="pad(00) -> split -> delay">
<span data-view="flip"></span>
</div>
</div>
</div>
</div>
</div>
<div class="otp otp-lg mt-9 justify-content-center" data-bs-otp
hx-include="[name='otp']"
hx-status:429="target:#otp-pane swap:outerHTML"
hx-status:4xx="swap:none"
hx-trigger="complete.bs.otpInput"
hx:post="@{/otp}"
hx-target="#otp-pane"
hx-swap="outerHTML"
>
<input name="otp" type="text" class="otp-input" maxlength="6"
autocomplete="one-time-code"
aria-label="Verification code">
</div>
<div id="flash"
hidden
class="alert flash theme-warning d-flex align-items-center"
role="alert"
hx-on="flash -> q('previous .otp').otp.clear();
clearTimeout(this._flashTimeout);
this.textContent = message;
this.hidden = !this.textContent;
if (!this.hidden) this._flashTimeout = setTimeout(() => { this.textContent = ''; this.hidden = true; }, 3000)"
>
</div>
</div>
<div th:fragment="otpLocked" id="otp-pane" class="md:col-10 mx-auto lg:col-5 position-relative text-center">
<p class="alert theme-warning" role="alert" th:text="#{otp.locked}">
Too many incorrect codes. Please request a new code and try again.
</p>
<button class="btn btn-primary"
hx:post="@{/otp/resend}"
hx-target="#otp-pane"
hx-swap="outerHTML"
th:text="#{otp.resend}">Send a new code</button>
</div>
<div th:fragment="otpExpired" id="otp-pane" class="md:col-10 mx-auto lg:col-5 position-relative text-center">
<p class="alert theme-warning" role="alert" th:text="#{otp.expired}">
One time password expired!
</p>
<button class="btn btn-primary"
hx:post="@{/otp/resend}"
hx-target="#otp-pane"
hx-swap="outerHTML"
th:text="#{otp.resend}">Send a new code</button>
</div>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!--
~ Copyright (c) 2026. RedINTER Comunicaciones SA de CV. All rights reserved.
~
~ This source code is the property of RedINTER Comunicaciones SA de CV and constitutes
~ proprietary and confidential software.
~
~ Reproduction, modification, distribution, disclosure, or use of this
~ source code without the copyright holder's prior written authorization
~ is prohibited, except as permitted by applicable law or a valid
~ license agreement.
-->
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout.html}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<section class="layout-content" layout:fragment="content">
<div class="container xl:col-10 2xl:col-8 px-4 py-5">
<div class="row align-items-center lg:g-5 py-5">
<div layout:insert="~{fragments/panel::left(#{push.title},#{push.description},#{push.description2})}"
class="lg:col-7 text-center lg:text-start">
</div>
<div class="md:col-10 mx-auto lg:col-5">
<div class="ratio ratio-16x9 rounded overflow-hidden shadow">
<video width="100%" class = "object-fit-contain border rounded" autoplay loop>
<source th:src="@{img/push.webm}" type="video/webm">
</video>
</div>
<button type="button" class="w-100 btn btn-lg btn-primary m-9"
hx:get="@{/push/now}"
hx-indicator="#spinner"
hx-trigger="click">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-broadcast-pin" viewBox="0 0 16 16">
<path d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707m2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708m5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708m2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM6 8a2 2 0 1 1 2.5 1.937V15.5a.5.5 0 0 1-1 0V9.937A2 2 0 0 1 6 8"/>
</svg>
<div id="spinner" class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true" role="status">
<span class="visually-hidden">Waiting...</span>
</div>
<span th:text="#{push.button}"></span>
</button>
</div>
</div>
</div>
</section>
<div th:fragment="push">
Done!
</div>
@@ -0,0 +1,55 @@
package com.redinterglobal.welcome.config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.security.authentication.TestingAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.core.user.DefaultOAuth2User
class MonitorAuthenticationSuccessHandlerTest {
private val handler = MonitorAuthenticationSuccessHandler()
@Test
fun `successful monitor login saves email in a secure cookie`() {
val request = MockHttpServletRequest().apply { isSecure = true }
val response = MockHttpServletResponse()
handler.onAuthenticationSuccess(request, response, authentication(" user@example.com "))
val cookie = requireNotNull(response.getCookie(MonitorAuthenticationSuccessHandler.COOKIE_NAME))
assertEquals("user@example.com", cookie.value)
assertEquals("/", cookie.path)
assertEquals(MonitorAuthenticationSuccessHandler.COOKIE_MAX_AGE_SECONDS, cookie.maxAge)
assertEquals(true, cookie.isHttpOnly)
assertEquals(true, cookie.secure)
assertEquals("Lax", cookie.getAttribute("SameSite"))
}
@Test
fun `successful monitor login without an email claim does not save a cookie`() {
val request = MockHttpServletRequest()
val response = MockHttpServletResponse()
val principal = DefaultOAuth2User(
listOf(SimpleGrantedAuthority("ROLE_MONITOR")),
mapOf("name" to "Monitor User"),
"name"
)
handler.onAuthenticationSuccess(request, response, TestingAuthenticationToken(principal, null))
assertNull(response.getCookie(MonitorAuthenticationSuccessHandler.COOKIE_NAME))
}
private fun authentication(email: String) = TestingAuthenticationToken(
DefaultOAuth2User(
listOf(SimpleGrantedAuthority("ROLE_MONITOR")),
mapOf("email" to email),
"email"
),
null
)
}
@@ -0,0 +1,98 @@
package com.redinterglobal.welcome.config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.mock.web.MockHttpSession
import org.springframework.security.oauth2.client.registration.ClientRegistration
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter
import org.springframework.security.oauth2.core.AuthorizationGrantType
import org.springframework.security.oauth2.core.ClientAuthenticationMethod
import org.springframework.security.oauth2.core.oidc.OidcScopes
import org.springframework.web.util.UriComponentsBuilder
class MonitorLoginHintAuthorizationRequestResolverTest {
private val resolver = MonitorLoginHintAuthorizationRequestResolver(registrationRepository())
@Test
fun `adds login hint from a matching pending session value`() {
val session = pendingHintSession()
val resolved = assertNotNull(resolver.resolve(authorizationRequest(session)))
assertEquals("user@example.com", resolved.additionalParameters["login_hint"])
assertNotNull(resolved.state)
assertNull(session.getAttribute(MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE))
}
@Test
fun `leaves authorization request unchanged without a pending login hint`() {
val resolved = assertNotNull(resolver.resolve(authorizationRequest()))
assertNull(resolved.additionalParameters["login_hint"])
assertNotNull(resolved.state)
}
@Test
fun `does not consume a hint for another registration`() {
val session = MockHttpSession().apply {
setAttribute(
MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE,
PendingMonitorLoginHint("another-idP", "user@example.com")
)
}
val resolved = assertNotNull(resolver.resolve(authorizationRequest(session)))
assertNull(resolved.additionalParameters["login_hint"])
assertNotNull(session.getAttribute(MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE))
}
@Test
fun `authorization redirect forwards the login hint to the identity provider`() {
val session = pendingHintSession()
val response = MockHttpServletResponse()
OAuth2AuthorizationRequestRedirectFilter(resolver).doFilter(authorizationRequest(session), response) { _, _ -> }
val parameters = UriComponentsBuilder.fromUriString(assertNotNull(response.redirectedUrl))
.build()
.queryParams
assertEquals("user@example.com", parameters.getFirst("login_hint"))
assertNotNull(parameters.getFirst("state"))
assertNull(session.getAttribute(MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE))
}
private fun authorizationRequest(session: MockHttpSession? = null) =
MockHttpServletRequest("GET", "/oauth2/authorization/monitor-idP").apply {
servletPath = "/oauth2/authorization/monitor-idP"
session?.let(::setSession)
}
private fun pendingHintSession() = MockHttpSession().apply {
setAttribute(
MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE,
PendingMonitorLoginHint("monitor-idP", "user@example.com")
)
}
private fun registrationRepository(): ClientRegistrationRepository = InMemoryClientRegistrationRepository(
ClientRegistration.withRegistrationId("monitor-idP")
.clientId("monitor-client")
.clientSecret("secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://idp.example.com/authorize")
.tokenUri("https://idp.example.com/token")
.jwkSetUri("https://idp.example.com/jwks")
.scope(OidcScopes.OPENID)
.build()
)
}
@@ -0,0 +1,114 @@
package com.redinterglobal.welcome.controller
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService
import com.redinterglobal.welcome.config.MonitorLoginHintAuthorizationRequestResolver
import com.redinterglobal.welcome.config.PendingMonitorLoginHint
import com.redinterglobal.welcome.config.WelcomeConfig
import com.redinterglobal.welcome.domain.EnrolledUser
import com.redinterglobal.welcome.domain.EnrolledUserRepo
import com.redinterglobal.welcome.service.EnrolledUserSseRegistry
import kotlin.test.Test
import kotlin.test.assertEquals
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.springframework.mock.env.MockEnvironment
import org.springframework.mock.web.MockHttpSession
import org.springframework.ui.ConcurrentModel
import org.springframework.web.servlet.mvc.support.RedirectAttributesModelMap
class MonitorControllerTest {
@Test
fun `login exposes remembered monitor email to the view`() {
val model = ConcurrentModel()
assertEquals("monitor/login", controller(mock(EnrolledUserRepo::class.java)).login(
model,
error = null,
monitorEmail = "user@example.com"
))
assertEquals("user@example.com", model.getAttribute("monitorEmail"))
}
@Test
fun `user list omits null repository entries`() {
val repository = mock(EnrolledUserRepo::class.java)
val user = EnrolledUser("user@example.com", "User", "otp")
`when`(repository.findAll()).thenReturn(repositoryValues(null, user))
assertEquals(listOf(user), controller(repository).getUserList())
}
@Test
fun `user list is empty when the repository only contains null entries`() {
val repository = mock(EnrolledUserRepo::class.java)
`when`(repository.findAll()).thenReturn(repositoryValues(null))
assertEquals(emptyList(), controller(repository).getUserList())
}
@Test
fun `monitor login stores the email as a pending login hint`() {
val welcomeConfig = WelcomeConfig(
appStores = emptyList(),
idp = mapOf("example.com" to "example")
)
val controller = MonitorController(
mock(EnrolledUserRepo::class.java),
mock(EnrolledUserSseRegistry::class.java),
mock(TurnstileValidationService::class.java),
MockEnvironment(),
welcomeConfig
)
val session = MockHttpSession()
assertEquals("redirect:htmx:/oauth2/authorization/example-idP", controller.handleLogin(
"user@example.com",
RedirectAttributesModelMap(),
session
))
assertEquals(
PendingMonitorLoginHint("example-idP", "user@example.com"),
session.getAttribute(MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE)
)
}
@Test
fun `invalid monitor login clears a pending login hint`() {
val session = MockHttpSession().apply {
setAttribute(
MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE,
PendingMonitorLoginHint("example-idP", "user@example.com")
)
}
assertEquals(
"redirect:htmx:/monitor/login",
controller(mock(EnrolledUserRepo::class.java)).handleLogin(
"invalid-email",
RedirectAttributesModelMap(),
session
)
)
assertEquals(
null,
session.getAttribute(MonitorLoginHintAuthorizationRequestResolver.PENDING_LOGIN_HINT_SESSION_ATTRIBUTE)
)
}
private fun controller(repository: EnrolledUserRepo) = MonitorController(
repository,
mock(EnrolledUserSseRegistry::class.java),
mock(TurnstileValidationService::class.java),
MockEnvironment(),
WelcomeConfig(appStores = emptyList()),
)
@Suppress("UNCHECKED_CAST")
private fun repositoryValues(vararg values: EnrolledUser?): List<EnrolledUser> =
values.toList() as List<EnrolledUser>
}
+233
View File
@@ -0,0 +1,233 @@
/*
* Copyright (C) 2025 Comunicaciones Productivas Castro y Asociados, S. de R. L. de C.V. - All Rights Reserved
*
* This source code is protected under international copyright law.
* All rights reserved and protected by the copyright holders.
* This file is confidential and only available to authorized individuals with the permission of the copyright holders.
* If you encounter this file and do not have permission, please contact the copyright holders and delete this file.
*/
import {fileURLToPath} from 'node:url';
import path from 'path';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import ChunksWebpackPlugin from 'chunks-webpack-plugin';
import webpack from 'webpack';
import autoprefixer from "autoprefixer";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Groups node_modules into one cacheable chunk per top-level package, so that
// bumping one dependency does not invalidate the others' [contenthash].
const vendorChunkName = (module) => {
const matched = module.context?.match(/[\\/]node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)/);
if (!matched) return 'vendor';
return `vendor-${matched[1].replace(/@/g, '').replace(/[\\/]/g, '-')}`;
};
export default(_env, argv) => {
const isProduction = argv.mode === 'production';
return {
entry: {
monitor: './src/main/resources/js/monitor.js',
'login-page': './src/main/resources/js/login-page.js',
download: './src/main/resources/js/download.js',
otp: './src/main/resources/js/otp.js',
enroll: './src/main/resources/js/enroll.js',
welcome: './src/main/resources/js/welcome.js',
done: './src/main/resources/js/done.js',
},
output: {
path: path.resolve(__dirname, './build/resources/main/static/dist'),
publicPath: '/dist/',
// thymepack resolves assets through chunks-manifest.json, so hashed
// filenames are transparent to the templates.
filename: isProduction ? '[name].[contenthash].js' : '[name].js',
chunkFilename: isProduction ? '[name].[contenthash].js' : '[name].js',
assetModuleFilename: isProduction ? '[name].[contenthash][ext]' : '[name][ext]',
clean: true
},
// Emit source maps as separate assets so browser DevTools can request
// them from /dist/ instead of relying on inline eval maps.
devtool: isProduction ? false : 'source-map',
mode: argv.mode,
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
},
optimization: {
minimize: isProduction,
// '...' keeps webpack's default JS minimizer (Terser) with its default options.
minimizer: [
'...',
new CssMinimizerPlugin({
minimizerOptions: {
preset: ['default', {
// Bootstrap uses relative-color calc() expressions that
// postcss-calc cannot parse yet. Preserve them for browsers.
calc: false
}]
}
})
],
splitChunks: {
chunks: 'all',
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
name: vendorChunkName,
reuseExistingChunk: true
}
}
}
},
performance: {
// preline's single 388 KiB module cannot be split; warning on every
// build adds no information. Revisit if the payload is trimmed.
hints: false
},
devServer: {
port: 8081,
compress: false,
hot: false,
liveReload: true,
static: {
directory: path.resolve(__dirname, './src/main/resources/static'),
publicPath: '/',
watch: true,
},
devMiddleware: {
writeToDisk: true,
},
watchFiles: [
'./src/main/resources/templates/*.html',
'./src/main/resources/templates/**/*.html',
'./src/main/resources/messages.properties',
'./src/main/resources/messages_*.properties',
'./src/main/resources/js/*.js',
'./src/main/resources/js/**/*.js',
'./src/main/resources/styles/**/*',
'./src/main/resources/static/**/*',
],
proxy: [
{
context: (pathname) => ![
'/dist/',
'/img/',
'/favicon/',
'/.well-known/',
'/site.webmanifest',
].some((path) => pathname.startsWith(path)),
target: "http://localhost:8080",
secure: false,
prependPath: false,
}
],
headers: {
'Cache-Control': 'no-store'
}
},
module: {
rules: [
{
// Consume Bootstrap's shipped maps before webpack creates
// its own bundle map. This prevents DevTools from trying
// to load Bootstrap's relative *.js.map files at /dist/.
test: /\.js$/,
enforce: 'pre',
include: path.resolve(__dirname, 'node_modules/bootstrap/js/dist'),
use: ['source-map-loader']
},
{ // Bootstrap
test: /\.scss$/,
use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
{ loader: 'css-loader' },
{ loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
autoprefixer,
// ...(isProduction ? [purgeUnusedCss] : [])
]
}
}
},
{ loader: 'sass-loader' }
]
},
{
test: /\.css$/,
use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
sourceMap: !isProduction
}
},
{
// @tailwindcss/postcss resolves @import/@source/@plugin and
// handles vendor prefixing through Lightning CSS.
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
autoprefixer
]
},
sourceMap: !isProduction
}
},
{
loader: 'sass-loader'
}
]
},
{
test: /\.woff2?$/,
type: "asset/resource",
},
]
},
resolve: {
// @qr-platform/qr-code.js includes a Node-only dynamic Buffer import.
// Its browser runtime never reaches that path, but Webpack still resolves it.
fallback: {
buffer: false,
},
modules: [
path.resolve(__dirname, 'src/main/resources'),
'node_modules'
],
extensions: [ '...' ],
},
plugins: [
new webpack.ProvidePlugin({
// _: 'lodash',
}),
new ChunksWebpackPlugin({
generateChunksManifest: true,
generateChunksFiles: false
}),
...(isProduction
? [new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[name].[contenthash].css'
})]
: []),
],
};
};