Skip to content
Snippets Groups Projects
Commit de580395 authored by Nicolas Pernoud's avatar Nicolas Pernoud
Browse files

feat: front created, handling routing and basic UI

parent db3a3770
No related branches found
No related tags found
No related merge requests found
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
document.getElementById(`glcpro-auth`).addEventListener("click", () => { document.getElementById(`glcpro-auth`).addEventListener("click", () => {
window.location.href = window.location.href =
"http://localhost:8080/oidc/auth?scope=openid%20profile&client_id=A_RANDOM_ID&redirect_uri=http://localhost:8081&response_type=code&state=A_RANDOM_STATE"; "http://localhost:8080/auth?scope=openid%20profile&client_id=A_RANDOM_ID&redirect_uri=http://localhost:8081&response_type=code&state=A_RANDOM_STATE";
}); });
// Try to get user if available (if not the error will redirect to #login)
const query = new URLSearchParams(window.location.search); const query = new URLSearchParams(window.location.search);
const code = query.get("code"); const code = query.get("code");
if (query != undefined && query != "") { if (query != undefined && query != "") {
......
...@@ -18,8 +18,7 @@ func CreateRootMux(staticDir string) *http.ServeMux { ...@@ -18,8 +18,7 @@ func CreateRootMux(staticDir string) *http.ServeMux {
fmt.Fprint(w, "OK") fmt.Fprint(w, "OK")
}) })
mainMux.Handle("/api/", http.StripPrefix("/api", apiMux)) mainMux.Handle("/api/", http.StripPrefix("/api", apiMux))
mainMux.Handle("/oidc/", http.StripPrefix("/oidc", oidcserver.CreateOIDCServer())) mainMux.Handle("/api/oidc/", http.StripPrefix("/api/oidc", oidcserver.CreateOIDCServer()))
mainMux.HandleFunc("/callback", oidcserver.HandleFranceConnectCallback)
// Serve static files falling back to serving index.html // Serve static files falling back to serving index.html
mainMux.Handle("/", middlewares.NoCache(http.FileServer(&common.FallBackWrapper{Assets: http.Dir(staticDir)}))) mainMux.Handle("/", middlewares.NoCache(http.FileServer(&common.FallBackWrapper{Assets: http.Dir(staticDir)})))
// Put it together into the main handler // Put it together into the main handler
......
package oidcserver package oidcserver
import ( import (
"encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
...@@ -17,6 +18,36 @@ import ( ...@@ -17,6 +18,36 @@ import (
type clientRequestData struct { type clientRequestData struct {
RedirectURI string RedirectURI string
State string State string
Sirent string
}
type SirenResponse struct {
Entreprise struct {
Siren string `json:"siren"`
CapitalSocial interface{} `json:"capital_social"`
NumeroTvaIntracommunautaire string `json:"numero_tva_intracommunautaire"`
FormeJuridique string `json:"forme_juridique"`
FormeJuridiqueCode string `json:"forme_juridique_code"`
NomCommercial string `json:"nom_commercial"`
ProcedureCollective bool `json:"procedure_collective"`
Enseigne interface{} `json:"enseigne"`
LibelleNafEntreprise string `json:"libelle_naf_entreprise"`
NafEntreprise string `json:"naf_entreprise"`
RaisonSociale string `json:"raison_sociale"`
SiretSiegeSocial string `json:"siret_siege_social"`
CodeEffectifEntreprise string `json:"code_effectif_entreprise"`
DateCreation int `json:"date_creation"`
Nom interface{} `json:"nom"`
Prenom interface{} `json:"prenom"`
DateRadiation interface{} `json:"date_radiation"`
CategorieEntreprise string `json:"categorie_entreprise"`
MandatairesSociaux []interface{} `json:"mandataires_sociaux"`
EtatAdministratif struct {
Value string `json:"value"`
DateCessation interface{} `json:"date_cessation"`
} `json:"etat_administratif"`
DiffusableCommercialement bool `json:"diffusable_commercialement"`
} `json:"entreprise"`
} }
// CreateOIDCServer creates a Open ID Connect Server as proxy to France Connect // CreateOIDCServer creates a Open ID Connect Server as proxy to France Connect
...@@ -57,7 +88,8 @@ func CreateOIDCServer() *http.ServeMux { ...@@ -57,7 +88,8 @@ func CreateOIDCServer() *http.ServeMux {
/// TODO : --replace by a proxy to france connect--done--, a screen with the SIRET/SIREN input, a call to API Entreprise and wrap everything (identity and company) into the opaque token /// TODO : --replace by a proxy to france connect--done--, a screen with the SIRET/SIREN input, a call to API Entreprise and wrap everything (identity and company) into the opaque token
// Store the client data in a cookie // Store the client data in a cookie
rd.State = query.Get("state") rd.State = query.Get("state")
tokens.Manager.StoreData(rd, "http://localhost:8080", "clientRequestData", 600*time.Second, w) // TODO : 60 seconds rd.Sirent = query.Get("sirent")
tokens.Manager.StoreData(rd, "localhost", "clientRequestData", 600*time.Second, w) // TODO : 60 seconds
// Redirect to France Connect with the callback as parameter // Redirect to France Connect with the callback as parameter
// TODO : France Connect parameters as env variables // TODO : France Connect parameters as env variables
...@@ -78,6 +110,96 @@ func CreateOIDCServer() *http.ServeMux { ...@@ -78,6 +110,96 @@ func CreateOIDCServer() *http.ServeMux {
// And that's all the reste will be handed by France Connect callback : get the France Connect identity, do the API Entreprise call, get the original parameters from the client and redirect to the original client // And that's all the reste will be handed by France Connect callback : get the France Connect identity, do the API Entreprise call, get the original parameters from the client and redirect to the original client
}) })
// Handles the France Connect Callback, get the user info from the token, and end the original response
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
// TODO : fcToken and client secret from env
fcToken := "https://fcp.integ01.dev-franceconnect.fr/api/v1/token"
fcUserInfo := "https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo?schema=openid"
fcClientID := "211286433e39cce01db448d80181bdfd005554b19cd51b3fe7943f6b3b86ab6e"
fcClientSecret := "2791a731e6a59f56b6b4dd0d08c9b1f593b5f3658b9fd731cb24248e2669af4b"
// TODO : Check the state
fcCode := r.URL.Query().Get("code")
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("redirect_uri", "http://localhost:8080/callback")
data.Set("client_id", fcClientID)
data.Set("client_secret", fcClientSecret)
data.Set("code", fcCode)
client := &http.Client{}
req, err := http.NewRequest(http.MethodPost, fcToken, strings.NewReader(data.Encode()))
if err != nil {
http.Error(w, "could not perform the request to France Connect token url", http.StatusInternalServerError)
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
resp, err := client.Do(req)
if err != nil {
http.Error(w, "could not perform the request to France Connect token url", http.StatusInternalServerError)
return
}
// Get the access_token information
rBody, _ := ioutil.ReadAll(resp.Body)
fmt.Printf("rBody: %v\n", string(rBody))
accessToken := regexp.MustCompile(`.*"access_token":"([^"]*)`).FindStringSubmatch(string(rBody))[1]
// Call the user info endpoint
req, err = http.NewRequest(http.MethodGet, fcUserInfo, nil)
if err != nil {
http.Error(w, "could not perform the request to France Connect userinfo url", http.StatusInternalServerError)
return
}
req.Header.Add("Authorization", "Bearer "+accessToken)
resp, err = client.Do(req)
if err != nil {
http.Error(w, "could not perform the request to France Connect userinfo url", http.StatusInternalServerError)
return
}
rBody, _ = ioutil.ReadAll(resp.Body)
fmt.Printf("rBody: %v\n", string(rBody))
// Get back the redirect url from the cookie
rd := clientRequestData{}
_, err = tokens.Manager.ExtractAndValidateToken(r, "clientRequestData", &rd, false)
fmt.Printf("Request Data:%v\n", rd)
if err != nil {
http.Error(w, "could not get the initial client request data from cookie", http.StatusInternalServerError)
return
}
// Get the data from the API Entreprise
// TODO : Real access, in place of mock
req, err = http.NewRequest(http.MethodGet, "http://localhost:8082/v2/entreprise", nil)
if err != nil {
http.Error(w, "could not perform the request to API Entreprise", http.StatusInternalServerError)
return
}
//req.Header.Add("Authorization", "Bearer "+accessToken)
resp, err = client.Do(req)
if err != nil {
http.Error(w, "could not perform the request to API Entreprise", http.StatusInternalServerError)
return
}
rBodyApi, _ := ioutil.ReadAll(resp.Body)
s := SirenResponse{}
json.Unmarshal(rBodyApi, &s)
// Match the siren to the requested siren
if s.Entreprise.Siren != rd.Sirent {
http.Redirect(w, req, "/matcher", http.StatusFound)
}
// TODO : redirect to the initial caller with an code that it actually the data bundled in an opaque token
///////////////////////////////////////////////////////////////////////////////////////////////////////
// TEMPORARY (FOR DEMO PURPOSES ONLY : SERIOUS SECURITY ISSUES) //
// Redirect to the initial client, with state and userinfo directly in place of authorisation code //
////////////////////////////////////////////////////////////////////////////////////////////////////
http.Redirect(w, req, rd.RedirectURI+"?code="+string(rBody)+string(rBodyApi), http.StatusFound)
})
// Returns access token back to the user // Returns access token back to the user
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
...@@ -122,87 +244,3 @@ func CreateOIDCServer() *http.ServeMux { ...@@ -122,87 +244,3 @@ func CreateOIDCServer() *http.ServeMux {
return mux return mux
} }
// Handles the France Connect Callback, get the user info from the token, and end the original response
// Not in the mux for the time being due to limits on the default France Connect Callbacks
func HandleFranceConnectCallback(w http.ResponseWriter, r *http.Request) {
// TODO : fcToken and client secret from env
fcToken := "https://fcp.integ01.dev-franceconnect.fr/api/v1/token"
fcUserInfo := "https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo?schema=openid"
fcClientID := "211286433e39cce01db448d80181bdfd005554b19cd51b3fe7943f6b3b86ab6e"
fcClientSecret := "2791a731e6a59f56b6b4dd0d08c9b1f593b5f3658b9fd731cb24248e2669af4b"
// TODO : Check the state
fcCode := r.URL.Query().Get("code")
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("redirect_uri", "http://localhost:8080/callback")
data.Set("client_id", fcClientID)
data.Set("client_secret", fcClientSecret)
data.Set("code", fcCode)
client := &http.Client{}
req, err := http.NewRequest(http.MethodPost, fcToken, strings.NewReader(data.Encode()))
if err != nil {
http.Error(w, "could not perform the request to France Connect token url", http.StatusInternalServerError)
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
resp, err := client.Do(req)
if err != nil {
http.Error(w, "could not perform the request to France Connect token url", http.StatusInternalServerError)
return
}
// Get the access_token information
rBody, _ := ioutil.ReadAll(resp.Body)
fmt.Printf("rBody: %v\n", string(rBody))
accessToken := regexp.MustCompile(`.*"access_token":"([^"]*)`).FindStringSubmatch(string(rBody))[1]
// Call the user info endpoint
req, err = http.NewRequest(http.MethodGet, fcUserInfo, nil)
if err != nil {
http.Error(w, "could not perform the request to France Connect userinfo url", http.StatusInternalServerError)
return
}
req.Header.Add("Authorization", "Bearer "+accessToken)
resp, err = client.Do(req)
if err != nil {
http.Error(w, "could not perform the request to France Connect userinfo url", http.StatusInternalServerError)
return
}
rBody, _ = ioutil.ReadAll(resp.Body)
fmt.Printf("rBody: %v\n", string(rBody))
// Get the data from the API Entreprise
// TODO : Real access, in place of mock
req, err = http.NewRequest(http.MethodGet, "http://localhost:8082/v2/entreprise", nil)
if err != nil {
http.Error(w, "could not perform the request to API Entreprise", http.StatusInternalServerError)
return
}
//req.Header.Add("Authorization", "Bearer "+accessToken)
resp, err = client.Do(req)
if err != nil {
http.Error(w, "could not perform the request to API Entreprise", http.StatusInternalServerError)
return
}
rBody2, _ := ioutil.ReadAll(resp.Body)
fmt.Printf("rBody: %v\n", string(rBody))
// TODO : redirect to the initial caller with an code that it actually the data bundled in an opaque token
///////////////////////////////////////////////////////////////////////////////////////////////////////
// TEMPORARY (FOR DEMO PURPOSES ONLY : SERIOUS SECURITY ISSUES) //
// Redirect to the initial client, with state and userinfo directly in place of authorisation code //
////////////////////////////////////////////////////////////////////////////////////////////////////
// Get back the redirect url from the cookie
rd := clientRequestData{}
_, err = tokens.Manager.ExtractAndValidateToken(r, "clientRequestData", &rd, false)
fmt.Printf("Request Data:%v\n", rd)
if err != nil {
http.Error(w, "could not get the initial client request data from cookie", http.StatusInternalServerError)
return
}
http.Redirect(w, req, rd.RedirectURI+"?code="+string(rBody)+string(rBody2), http.StatusFound)
}
export const windowTitle = "Vestibule";
export const navTitle = "Vestibule";
export const loginModes = { inmemory: true, oauth2: true };
web/assets/brand/favicon.ico

1.74 KiB

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="512" height="512" viewBox="0 0 135.46667 135.46667" version="1.1" id="svg8" inkscape:version="0.92.4 (5da689c313, 2019-01-14)" sodipodi:docname="logo.svg" inkscape:export-filename="/home/nicolas/dev/Vestibule/web/assets/brand/logo.png" inkscape:export-xdpi="96" inkscape:export-ydpi="96">
<defs id="defs2" />
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="0.35" inkscape:cx="-136.97074" inkscape:cy="-24.502816" inkscape:document-units="mm" inkscape:current-layer="layer1" showgrid="false" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" inkscape:window-width="1870" inkscape:window-height="1019" inkscape:window-x="50" inkscape:window-y="27" inkscape:window-maximized="1" units="px" />
<metadata id="metadata5">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g inkscape:label="Calque 1" inkscape:groupmode="layer" id="layer1" transform="translate(-60.098213,-0.89344866)">
<rect style="fill:#000000;fill-opacity:1;stroke-width:0.70605141" id="rect817" width="110.94254" height="135.46666" x="72.360275" y="0.89345223" inkscape:export-filename="/home/nicolas/dev/proxhibou_temp/web/favicon.png" inkscape:export-xdpi="96" inkscape:export-ydpi="96" />
<path style="fill:#3f51b5;fill-opacity:1;stroke-width:0.83692497" d="M 85.498214,7.8627842 170.16488,27.58243 v 78.41994 l -84.666666,23.38841 z" id="rect820" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccc" inkscape:export-filename="/home/nicolas/dev/proxhibou_temp/web/favicon.png" inkscape:export-xdpi="96" inkscape:export-ydpi="96" />
<g aria-label="V" style="font-style:normal;font-weight:normal;font-size:10.58333302px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332" id="text825" transform="matrix(9.8626344,0,0,8.4586653,-644.09366,-815.57641)" inkscape:export-filename="/home/nicolas/dev/proxhibou_temp/web/favicon.png" inkscape:export-xdpi="96" inkscape:export-ydpi="96">
<path d="m 77.678538,108.38988 -2.945557,-7.71529 h 1.090373 l 2.444295,6.49572 2.449463,-6.49572 h 1.085205 l -2.940389,7.71529 z" style="stroke-width:0.26458332" id="path827" inkscape:connector-curvature="0" />
</g>
</g>
</svg>
{
"name": "GLC Pro",
"short_name": "GLC Pro",
"theme_color": "#132f49",
"background_color": "#d3d3d3",
"display": "standalone",
"Scope": "/",
"start_url": "/",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"splash_pages": null
}
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 100 100"
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#">
<metadata>
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<circle cx="50" cy="50" r="45" fill="none" stroke="#b0bec5" stroke-width="5" />
<path d="m5 50c0-24.853 20.147-45 45-45" fill="none" stroke="#3f51b5" stroke-linecap="round" stroke-width="5">
<animateTransform attributeName="transform" dur="1s" from="0 50 50" repeatDur="indefinite" to="360 50 50" type="rotate" />
</path>
</svg>
// Imports
import { HandleError } from "/services/common/errors.js";
export async function mount(where) {
const authComponent = new Auth();
await authComponent.mount(where);
}
class Auth {
constructor() {}
async mount(mountpoint) {
document.getElementById(mountpoint).innerHTML = /* HTML */ `
<h1>Bienvenue sur GLC Pro</h1>
<p>Veuillez saisir le numéro de l'entreprise ou l'établissement pour lequel vous souhaitez faire une demande :</p>
<p>Pour ce POC, le seul SIREN accepté est celui de la Métropole de Lyon : 200046977 . Toute personne qui fait la demande avec ce SIREN sera habilité, toute autre SIREN donnera lieu à une proposition de délégation</p>
<p>Pour améliorer et avoir un comportement réaliste, il faudrait des comptes de test France Connect représentant des dirigeants dans l'API Entreprise</p>
<input type="text" id="auth-sirent"></input>
<button id="auth-do">OK</button>
`;
// TODO : Check SIREN/T is in input and looks like a SIREN/T
document.getElementById(`auth-do`).addEventListener("click", () => {
// Add the SIREN/T to the request
let searchParams = new URLSearchParams(window.location.search);
searchParams.set("sirent", document.getElementById(`auth-sirent`).value);
var newRelativePathQuery =
window.location.pathname + "?" + searchParams.toString();
history.pushState(null, "", newRelativePathQuery);
// Transfer to the backend
window.location.pathname = "/api/oidc/auth";
});
}
}
// Imports
import { HandleError } from "/services/common/errors.js";
export async function mount(where) {
const matcherComponent = new Matcher();
await matcherComponent.mount(where);
}
class Matcher {
constructor() {}
async mount(mountpoint) {
document.getElementById(mountpoint).innerHTML = /* HTML */ `
<h1>Bienvenue sur GLC Pro - demande de délégation</h1>
<p>
Vous n'êtes pas le directeur de l'entreprise ou de l'établissement
demandé.
</p>
<p>
Voulez vous qu'une demande de mandatement soit envoyée au dirigeant ?
</p>
<button id="matcher-do">OK</button>
<button id="matcher-cancel">Annuler</button>
`;
document.getElementById(`matcher-do`).addEventListener("click", () => {
alert("Un mail viens d'être envoyé au dirigeant !");
});
}
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GLC Pro</title> <title>GLC Pro</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="assets/brand/manifest.json" />
<script defer type="module" src="main.js"></script>
</head> </head>
<body> <body>
<h1>Hello World</h1> <!-- Main content-->
<section id="main"></section>
</body> </body>
</html> </html>
import * as Auth from "/components/auth/auth.js";
import * as Matcher from "/components/matcher/matcher.js";
const mountPoint = document.getElementById("main");
document.addEventListener("DOMContentLoaded", async () => {
window.addEventListener("hashchange", navigate);
navigate();
});
async function navigate() {
console.log(location.pathname);
switch (location.pathname) {
case "/auth":
load(mountPoint, async function () {
await Auth.mount("main");
});
break;
case "/matcher":
load(mountPoint, async function () {
await Matcher.mount("main");
});
break;
case "/callback":
//Redirect to server callback
location.pathname = "/api/oidc/callback";
break;
default:
location.pathname = "auth";
break;
}
}
async function load(element, domAlteration) {
// Start the alteration
const alteration = domAlteration();
await alteration; // Await for alteration end
}
export let GID = (obj, id) => {
return document.getElementById(obj.prefix + id);
};
export function RandomString(length) {
let text = "";
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
export function EncodeURIWithSpecialsCharacters(str) {
return encodeURI(str).replace(/[!'()*]/g, escape);
}
export function IsEmpty(obj) {
return Object.keys(obj).length === 0;
}
// Imports
import * as Messages from "/services/messages/messages.js";
export function HandleError(error) {
Messages.Show("is-warning", error.message);
console.error(error.message);
}
// Imports
let offset = 0;
let messages = [];
export function Show(bulmaClass, message) {
// Remove duplicate messages
if (!messages.includes(message)) {
let msg = document.createElement("div");
msg.style.marginBottom = offset.toString() + "px";
msg.innerText = message;
msg.classList.add("notification");
msg.classList.add(bulmaClass);
const delBtn = document.createElement("button");
delBtn.classList.add("delete");
msg.appendChild(delBtn);
document.body.appendChild(msg);
const height = msg.offsetHeight + 1;
offset = offset + height;
messages.push(message);
const timer = setTimeout(function () {
removeMsg(msg, message, height);
}, 5000);
delBtn.addEventListener("click", function () {
removeMsg(msg, message, height);
clearTimeout(timer);
});
}
}
async function removeMsg(msg, message, height) {
const index = messages.indexOf(message);
if (index > -1) {
messages.splice(index, 1);
}
msg.parentNode.removeChild(msg);
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment