got most of the login functionality together
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* Amsterdam Web Communities System
|
||||||
|
* Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
// The database package contains database management and storage logic.
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditRecord holds an audit record instance.
|
||||||
|
type AuditRecord struct {
|
||||||
|
Record int64 `db:"record"`
|
||||||
|
OnDate time.Time `db:"on_date"`
|
||||||
|
Event int32 `db:"event"`
|
||||||
|
Uid int32 `db:"uid"`
|
||||||
|
CommId int32 `db:"commid"`
|
||||||
|
IP *string `db:"ip"`
|
||||||
|
Data1 *string `db:"data1"`
|
||||||
|
Data2 *string `db:"data2"`
|
||||||
|
Data3 *string `db:"data3"`
|
||||||
|
Data4 *string `db:"data4"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are the audit record types.
|
||||||
|
const (
|
||||||
|
AuditPublishToFrontPage = 1
|
||||||
|
AuditLoginOK = 101
|
||||||
|
AuditLoginFail = 102
|
||||||
|
AuditAccountCreated = 103
|
||||||
|
AuditVerifyEmailOK = 104
|
||||||
|
AuditVerifyEmailFail = 105
|
||||||
|
AuditSetUserContactInfo = 106
|
||||||
|
AuditResendEmailConfirm = 107
|
||||||
|
AuditChangePassword = 108
|
||||||
|
AuditAdminSetUserContectInfo = 109
|
||||||
|
AuditAdminChangeUserPassword = 110
|
||||||
|
AuditAdminChangeUserAccount = 111
|
||||||
|
AuditAdminSetAccountSecurity = 112
|
||||||
|
AuditAdminLockUnlockAccount = 113
|
||||||
|
)
|
||||||
|
|
||||||
|
/* AmNewAudit creates a new audit record.
|
||||||
|
* Parameters:
|
||||||
|
* rectype - Audit record type.
|
||||||
|
* uid - User ID of the user.
|
||||||
|
* ip - User's IP address.
|
||||||
|
* data - Argument data values for the audit record.
|
||||||
|
* Returns:
|
||||||
|
* The audit record pointer.
|
||||||
|
*/
|
||||||
|
func AmNewAudit(rectype int32, uid int32, ip string, data ...string) *AuditRecord {
|
||||||
|
rc := AuditRecord{Event: rectype, Uid: uid, CommId: 0}
|
||||||
|
if len(ip) > 0 {
|
||||||
|
rc.IP = &ip
|
||||||
|
}
|
||||||
|
if data != nil {
|
||||||
|
l := len(data)
|
||||||
|
if l > 0 {
|
||||||
|
rc.Data1 = &(data[0])
|
||||||
|
}
|
||||||
|
if l > 1 {
|
||||||
|
rc.Data2 = &(data[1])
|
||||||
|
}
|
||||||
|
if l > 2 {
|
||||||
|
rc.Data3 = &(data[2])
|
||||||
|
}
|
||||||
|
if l > 3 {
|
||||||
|
rc.Data4 = &(data[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &rc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store stores the audit record in the database.
|
||||||
|
func (ar *AuditRecord) Store() error {
|
||||||
|
if ar.Record > 0 {
|
||||||
|
return fmt.Errorf("audit record %d already stored", ar.Record)
|
||||||
|
}
|
||||||
|
moment := time.Now()
|
||||||
|
rs, err := amdb.Exec(`INSERT INTO audit (on_date, event, uid, commid, ip, data1, data2, data3, data4)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, moment, ar.Event, ar.Uid, ar.CommId, ar.IP,
|
||||||
|
ar.Data1, ar.Data2, ar.Data3, ar.Data4)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ar.Record, _ = rs.LastInsertId()
|
||||||
|
ar.OnDate = moment
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -10,11 +10,15 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lru "github.com/hashicorp/golang-lru"
|
lru "github.com/hashicorp/golang-lru"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User represents a user in the Amsterdam database.
|
// User represents a user in the Amsterdam database.
|
||||||
@@ -83,6 +87,32 @@ func AmGetUser(uid int32) (*User, error) {
|
|||||||
return rc.(*User), err
|
return rc.(*User), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AmGetUserByName returns a reference to the specified user.
|
||||||
|
* Parameters:
|
||||||
|
* name - The username of the user.
|
||||||
|
* Returns:
|
||||||
|
* Pointer to User containing user data, or nil
|
||||||
|
* Standard Go error status
|
||||||
|
*/
|
||||||
|
func AmGetUserByName(name string) (*User, error) {
|
||||||
|
var dbdata []User
|
||||||
|
err := amdb.Select(&dbdata, "SELECT * FROM users WHERE username = ?", name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(dbdata) > 1 {
|
||||||
|
return nil, fmt.Errorf("AmGetUserByName(\"%s\"): too many responses(%d)", name, len(dbdata))
|
||||||
|
}
|
||||||
|
getUserMutex.Lock()
|
||||||
|
defer getUserMutex.Unlock()
|
||||||
|
rc, ok := userCache.Get(dbdata[0].Uid)
|
||||||
|
if !ok {
|
||||||
|
rc = &(dbdata[0])
|
||||||
|
userCache.Add(dbdata[0].Uid, rc)
|
||||||
|
}
|
||||||
|
return rc.(*User), nil
|
||||||
|
}
|
||||||
|
|
||||||
// getAnonUserID retrieves the UID of the "anonymous" user from the database.
|
// getAnonUserID retrieves the UID of the "anonymous" user from the database.
|
||||||
func getAnonUserID() (int32, error) {
|
func getAnonUserID() (int32, error) {
|
||||||
if anonUid < 0 {
|
if anonUid < 0 {
|
||||||
@@ -130,3 +160,69 @@ func AmGetAnonUser() (*User, error) {
|
|||||||
}
|
}
|
||||||
return rc, err
|
return rc, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashPassword hashes the password value.
|
||||||
|
func hashPassword(password string) string {
|
||||||
|
if len(password) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
hasher := sha1.New()
|
||||||
|
hasher.Write([]byte(password))
|
||||||
|
hashBytes := hasher.Sum(nil)
|
||||||
|
return hex.EncodeToString(hashBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchUser updates the last access time for the user.
|
||||||
|
func touchUser(user *User) {
|
||||||
|
user.Mutex.Lock()
|
||||||
|
defer user.Mutex.Unlock()
|
||||||
|
moment := time.Now()
|
||||||
|
_, _ = amdb.Exec("UPDATE user SET lastaccess = ? WHERE uid = ?", moment, user.Uid)
|
||||||
|
user.LastAccess = &moment
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AmAuthenticateUser authenticates a user by name and password.
|
||||||
|
* Parameters:
|
||||||
|
* name - The user name to try.
|
||||||
|
* password - The password to try.
|
||||||
|
* remote_ip - The remote IP address, for audit records.
|
||||||
|
* Returns:
|
||||||
|
* The User pointer if authenticated, or nil if not.
|
||||||
|
* Standard Go error status.
|
||||||
|
*/
|
||||||
|
func AmAuthenticateUser(name string, password string, remote_ip string) (*User, error) {
|
||||||
|
log.Debugf("AmAuthenicate() authenticating user %s...", name)
|
||||||
|
var ar *AuditRecord = nil
|
||||||
|
defer func() {
|
||||||
|
if ar != nil {
|
||||||
|
go ar.Store()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
user, err := AmGetUserByName(name)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("...user not found")
|
||||||
|
ar = AmNewAudit(AuditLoginFail, 0, remote_ip, fmt.Sprintf("Bad username: %s", name))
|
||||||
|
return nil, errors.New("the user account you have specified does not exist; please try again")
|
||||||
|
}
|
||||||
|
if user.IsAnon {
|
||||||
|
log.Error("...user is the Anonymous Honyak, can't explicitly log in")
|
||||||
|
ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Anonymous user")
|
||||||
|
return nil, errors.New("this account cannot be explicitly logged into; please try again")
|
||||||
|
}
|
||||||
|
if user.Lockout {
|
||||||
|
log.Error("...user is locked out by the admin")
|
||||||
|
ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Account locked out")
|
||||||
|
return nil, errors.New("this account has been administratively locked; please contact the system administrator for assistance")
|
||||||
|
}
|
||||||
|
h := hashPassword(password)
|
||||||
|
if h != user.Passhash {
|
||||||
|
log.Warn("...invalid password")
|
||||||
|
ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Bad password")
|
||||||
|
return nil, errors.New("the password you have specified is incorrect; please try again")
|
||||||
|
}
|
||||||
|
log.Debug("...authenticated")
|
||||||
|
touchUser(user)
|
||||||
|
ar = AmNewAudit(AuditLoginOK, user.Uid, remote_ip)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"git.erbosoft.com/amy/amsterdam/database"
|
||||||
"git.erbosoft.com/amy/amsterdam/ui"
|
"git.erbosoft.com/amy/amsterdam/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ func Login(ctxt ui.AmContext) (string, any, error) {
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
target = "/"
|
target = "/"
|
||||||
}
|
}
|
||||||
|
// If user is already logged in, this is a no-op.
|
||||||
|
if !ctxt.CurrentUser().IsAnon {
|
||||||
|
return "redirect", target, nil
|
||||||
|
}
|
||||||
|
|
||||||
action := dlg.WhichButton(ctxt)
|
action := dlg.WhichButton(ctxt)
|
||||||
if action == "cancel" {
|
if action == "cancel" {
|
||||||
@@ -66,9 +72,22 @@ func Login(ctxt ui.AmContext) (string, any, error) {
|
|||||||
}
|
}
|
||||||
if action == "remind" {
|
if action == "remind" {
|
||||||
// TODO: send password reminder
|
// TODO: send password reminder
|
||||||
return dlg.Render(ctxt)
|
dlg.Field("pass").Value = ""
|
||||||
|
return dlg.RenderError(ctxt, "Password reminder has been sent to your E-mail address.")
|
||||||
}
|
}
|
||||||
|
if action == "login" {
|
||||||
|
// authenticate the user
|
||||||
|
user, uerr := database.AmAuthenticateUser(dlg.Field("user").Value, dlg.Field("pass").Value, ctxt.RemoteIP())
|
||||||
|
if uerr != nil {
|
||||||
|
dlg.Field("pass").Value = ""
|
||||||
|
return dlg.RenderError(ctxt, uerr.Error())
|
||||||
|
}
|
||||||
|
ctxt.ReplaceUser(user)
|
||||||
|
// TODO: cookie set if required
|
||||||
|
// TODO: bounce to E-mail verify if we can do so
|
||||||
|
return "redirect", target, nil
|
||||||
|
}
|
||||||
|
err = errors.New("no known button click on POST to login function")
|
||||||
}
|
}
|
||||||
return ui.ErrorPage(ctxt, err)
|
return ui.ErrorPage(ctxt, err)
|
||||||
}
|
}
|
||||||
@@ -90,7 +109,7 @@ func NewAccountUserAgreement(ctxt ui.AmContext) (string, any, error) {
|
|||||||
|
|
||||||
// If user is already logged in, this is an error.
|
// If user is already logged in, this is an error.
|
||||||
if !ctxt.CurrentUser().IsAnon {
|
if !ctxt.CurrentUser().IsAnon {
|
||||||
return ui.ErrorPage(ctxt, fmt.Errorf("You cannot create a new account while logged in on an existing one. You must log out first."))
|
return ui.ErrorPage(ctxt, fmt.Errorf("you cannot create a new account while logged in on an existing one. You must log out first"))
|
||||||
}
|
}
|
||||||
|
|
||||||
ctxt.VarMap().Set("target", target)
|
ctxt.VarMap().Set("target", target)
|
||||||
@@ -115,7 +134,7 @@ func NewAccountForm(ctxt ui.AmContext) (string, any, error) {
|
|||||||
|
|
||||||
// If user is already logged in, this is an error.
|
// If user is already logged in, this is an error.
|
||||||
if !ctxt.CurrentUser().IsAnon {
|
if !ctxt.CurrentUser().IsAnon {
|
||||||
return ui.ErrorPage(ctxt, fmt.Errorf("You cannot create a new account while logged in on an existing one. You must log out first."))
|
return ui.ErrorPage(ctxt, fmt.Errorf("you cannot create a new account while logged in on an existing one. You must log out first"))
|
||||||
}
|
}
|
||||||
|
|
||||||
dlg, err := ui.AmLoadDialog("newaccount")
|
dlg, err := ui.AmLoadDialog("newaccount")
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ type AmContext interface {
|
|||||||
RC() int
|
RC() int
|
||||||
OutputType() string
|
OutputType() string
|
||||||
Parameter(string) string
|
Parameter(string) string
|
||||||
|
RemoteIP() string
|
||||||
Render(string) error
|
Render(string) error
|
||||||
|
ReplaceUser(*database.User)
|
||||||
SubRender(string) ([]byte, error)
|
SubRender(string) ([]byte, error)
|
||||||
Session() *sessions.Session
|
Session() *sessions.Session
|
||||||
SetOutputType(string)
|
SetOutputType(string)
|
||||||
@@ -121,6 +123,11 @@ func (c *amContext) Parameter(name string) string {
|
|||||||
return rc
|
return rc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoteIP returns the remote IP address.
|
||||||
|
func (c *amContext) RemoteIP() string {
|
||||||
|
return c.echoContext.RealIP()
|
||||||
|
}
|
||||||
|
|
||||||
/* Render renders a template to the output. Called at the top level only.
|
/* Render renders a template to the output. Called at the top level only.
|
||||||
* Parameters:
|
* Parameters:
|
||||||
* name = The name of the tempate to be rendered.
|
* name = The name of the tempate to be rendered.
|
||||||
@@ -131,6 +138,14 @@ func (c *amContext) Render(name string) error {
|
|||||||
return c.echoContext.Render(c.httprc, name, c)
|
return c.echoContext.Render(c.httprc, name, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ReplaceUser replaces the current user in the context.
|
||||||
|
* Parameters:
|
||||||
|
* u - New user to associate with the context.
|
||||||
|
*/
|
||||||
|
func (c *amContext) ReplaceUser(u *database.User) {
|
||||||
|
c.session.Values["user_id"] = u.Uid
|
||||||
|
}
|
||||||
|
|
||||||
// Scratchpad returns the per-request scratchpad for values.
|
// Scratchpad returns the per-request scratchpad for values.
|
||||||
func (c *amContext) Scratchpad() map[string]any {
|
func (c *amContext) Scratchpad() map[string]any {
|
||||||
if c.scratchpad == nil {
|
if c.scratchpad == nil {
|
||||||
|
|||||||
@@ -124,6 +124,20 @@ func (d *Dialog) Render(ctxt AmContext) (string, any, error) {
|
|||||||
return "framed_template", "dialog.jet", nil
|
return "framed_template", "dialog.jet", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RenderError sets up the rendering parameters to send this dialog to the output with an error message.
|
||||||
|
* Parameters:
|
||||||
|
* ctxt - The AmContext for this request.
|
||||||
|
* errormessage - The error message to be displayed.
|
||||||
|
* Returns:
|
||||||
|
* Command string dictating what to be rendered.
|
||||||
|
* Data as a parameter for the command string.
|
||||||
|
* Standard Go error status.
|
||||||
|
*/
|
||||||
|
func (d *Dialog) RenderError(ctxt AmContext, errormessage string) (string, any, error) {
|
||||||
|
ctxt.VarMap().Set("amsterdam_errorMessage", errormessage)
|
||||||
|
return d.Render(ctxt)
|
||||||
|
}
|
||||||
|
|
||||||
/* LoadFromForm loads the values in a dialog from the form fields in the request.
|
/* LoadFromForm loads the values in a dialog from the form fields in the request.
|
||||||
* Parameters:
|
* Parameters:
|
||||||
* ctxt - The AmContext for this request.
|
* ctxt - The AmContext for this request.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"git.erbosoft.com/amy/amsterdam/config"
|
"git.erbosoft.com/amy/amsterdam/config"
|
||||||
"github.com/CloudyKit/jet/v6"
|
"github.com/CloudyKit/jet/v6"
|
||||||
@@ -36,10 +37,13 @@ var static_views embed.FS
|
|||||||
// views is the main Jet template repository.
|
// views is the main Jet template repository.
|
||||||
var views *jet.Set
|
var views *jet.Set
|
||||||
|
|
||||||
|
// cachedCountryList is the cached country list after sorting.
|
||||||
var cachedCountryList []countries.CountryCode = nil
|
var cachedCountryList []countries.CountryCode = nil
|
||||||
|
|
||||||
|
// countryListMutex control access to internalGetCountryList.
|
||||||
var countryListMutex sync.Mutex
|
var countryListMutex sync.Mutex
|
||||||
|
|
||||||
|
// internalGetCountryList is a wrapper around countries.All() that sorts it by country name.
|
||||||
func internalGetCountryList() []countries.CountryCode {
|
func internalGetCountryList() []countries.CountryCode {
|
||||||
countryListMutex.Lock()
|
countryListMutex.Lock()
|
||||||
defer countryListMutex.Unlock()
|
defer countryListMutex.Unlock()
|
||||||
@@ -126,6 +130,21 @@ func makeYearRange(a jet.Arguments) reflect.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CapitalizeString changes the first character of trhe string to a capital.
|
||||||
|
* Parameters:
|
||||||
|
* s - The string to be capitalized.
|
||||||
|
* Returns:
|
||||||
|
* The capitalized string.
|
||||||
|
*/
|
||||||
|
func CapitalizeString(s string) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > 0 {
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// SetupTemplates is called to set up the template renderer after the configuration is loaded.
|
// SetupTemplates is called to set up the template renderer after the configuration is loaded.
|
||||||
func SetupTemplates() {
|
func SetupTemplates() {
|
||||||
views = jet.NewSet(
|
views = jet.NewSet(
|
||||||
@@ -143,6 +162,11 @@ func SetupTemplates() {
|
|||||||
views.AddGlobalFunc("MakeIntRange", makeIntRange)
|
views.AddGlobalFunc("MakeIntRange", makeIntRange)
|
||||||
views.AddGlobalFunc("MakeYearRange", makeYearRange)
|
views.AddGlobalFunc("MakeYearRange", makeYearRange)
|
||||||
|
|
||||||
|
views.AddGlobalFunc("CapitalizeString", func(a jet.Arguments) reflect.Value {
|
||||||
|
s := a.Get(0).Convert(reflect.TypeFor[string]()).String()
|
||||||
|
return reflect.ValueOf(CapitalizeString(s))
|
||||||
|
})
|
||||||
|
|
||||||
// preload the country list in the background
|
// preload the country list in the background
|
||||||
go internalGetCountryList()
|
go internalGetCountryList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,20 @@
|
|||||||
<p class="text-black text-sm mb-6">Required fields are marked with a <span class="text-red-600">*</span>.</p>
|
<p class="text-black text-sm mb-6">Required fields are marked with a <span class="text-red-600">*</span>.</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if isset(amsterdam_errorMessage) }}
|
||||||
|
<!-- Error Message Banner -->
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6 hidden" id="error-banner">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span class="text-red-500 text-xl">⚠️</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium" id="error-message">{{ CapitalizeString(amsterdam_errorMessage) }}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div class="bg-gray-50 p-6 rounded-lg">
|
<div class="bg-gray-50 p-6 rounded-lg">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{{ range amsterdam_dialog.Fields }}
|
{{ range amsterdam_dialog.Fields }}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
<h1 class="text-blue-800 text-4xl font-bold mb-2">Amsterdam Internal Server Error</h1>
|
<h1 class="text-blue-800 text-4xl font-bold mb-2">Amsterdam Internal Server Error</h1>
|
||||||
<hr class="border-2 border-gray-400 w-4/5 mb-4">
|
<hr class="border-2 border-gray-400 w-4/5 mb-4">
|
||||||
<p class="text-black text-sm mb-4">
|
<p class="text-black text-sm mb-4">
|
||||||
The Amsterdam server encountered an error: {{ error }}
|
The Amsterdam server encountered an error: {{ CapitalizeString(error) }}.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-black text-sm mb-4">
|
<p class="text-black text-sm mb-4">
|
||||||
<a href="/">Click here</a> to return to the home page.
|
<a href="/">Click here</a> to return to the home page.
|
||||||
|
|||||||
Reference in New Issue
Block a user